diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4100cf6bbc..bbc3264f86 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.809.0" + ".": "1.811.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d82cc8a185..0722ccb094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # 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) + + +### Features + +* **ai-sessions:** turn skills on by default, and group them by folder ([#11058](https://github.com/windmill-labs/windmill/issues/11058)) ([d8b9174](https://github.com/windmill-labs/windmill/commit/d8b9174235b97d0b4ef9f281e20e5704182d8dcb)) +* background and wait_seconds for run_script, skip preprocessor ([#11092](https://github.com/windmill-labs/windmill/issues/11092)) ([2939c2d](https://github.com/windmill-labs/windmill/commit/2939c2dd4b129640d87ef45c7a24c6f215a3239a)) +* give the chat the full MCP tool schema, and mark calls with the provider icon ([#11086](https://github.com/windmill-labs/windmill/issues/11086)) ([e7c6f85](https://github.com/windmill-labs/windmill/commit/e7c6f85553bd8efb0f7af0f488f615ac93c496ae)) +* let apps hide the viewer login status on public urls ([#11089](https://github.com/windmill-labs/windmill/issues/11089)) ([6056ec7](https://github.com/windmill-labs/windmill/commit/6056ec7148bce9f8ed171dd29f544696c335de7d)) +* remove the viewer login status badge from public apps ([#11090](https://github.com/windmill-labs/windmill/issues/11090)) ([75d7bee](https://github.com/windmill-labs/windmill/commit/75d7bee178886461fe49090d606a708f25c02d1a)) +* run a deployed flow through the chat's argument form ([#11085](https://github.com/windmill-labs/windmill/issues/11085)) ([b50de89](https://github.com/windmill-labs/windmill/commit/b50de8947908f1a5a4e9472afe6c0ecd25892e93)) +* run a flow test through the chat's argument form ([#11069](https://github.com/windmill-labs/windmill/issues/11069)) ([172d6c2](https://github.com/windmill-labs/windmill/commit/172d6c275b92b39d13848b543df9db10148e94ef)) + + +### Bug Fixes + +* attach TLS to gRPC OTLP exporters for https endpoints ([#11078](https://github.com/windmill-labs/windmill/issues/11078)) ([f915ed6](https://github.com/windmill-labs/windmill/commit/f915ed6a46e14341ddb213e869090edb68763032)) +* **dbt:** stop dbt sending anonymous usage stats from workers ([#11091](https://github.com/windmill-labs/windmill/issues/11091)) ([d539e86](https://github.com/windmill-labs/windmill/commit/d539e8674f2bf92d6c10b182ed4a1073714062c0)) +* keep pinned import versions of imported scripts in bun lockfiles ([#11082](https://github.com/windmill-labs/windmill/issues/11082)) ([57f8b08](https://github.com/windmill-labs/windmill/commit/57f8b0826ad61cb118d0cafbbc9203327d858940)) +* let admins and background sync reach private git hosts ([#11084](https://github.com/windmill-labs/windmill/issues/11084)) ([fa53099](https://github.com/windmill-labs/windmill/commit/fa53099e2b87a8676c5d6a18e77844e17ff45efd)) +* serve instance env settings at the documented /settings/local path ([#11075](https://github.com/windmill-labs/windmill/issues/11075)) ([f8f7c00](https://github.com/windmill-labs/windmill/commit/f8f7c0009f32c1440566420725b12e78ca804b03)) +* show symlinked files in the git repo viewer ([#11081](https://github.com/windmill-labs/windmill/issues/11081)) ([e6d4f44](https://github.com/windmill-labs/windmill/commit/e6d4f44a6122dab47fbee2a2a2b4330a62841bed)) +* support gzip and zstd compression for OTLP export over gRPC ([#11077](https://github.com/windmill-labs/windmill/issues/11077)) ([b156778](https://github.com/windmill-labs/windmill/commit/b156778da24e3827f723e503f80df68de87ddf7e)) +* unpin only the specifiers in the bundle a bun modules run executes ([#11083](https://github.com/windmill-labs/windmill/issues/11083)) ([30ffdbe](https://github.com/windmill-labs/windmill/commit/30ffdbecc15270562ceed3030c50a1cf81b1195c)) + + +### Performance Improvements + +* lazy-load the low-code runtime on public app pages ([#11087](https://github.com/windmill-labs/windmill/issues/11087)) ([e651b4c](https://github.com/windmill-labs/windmill/commit/e651b4cd63c3bd64a8739c9f60c6dfa437f19b4b)) + ## [1.809.0](https://github.com/windmill-labs/windmill/compare/v1.808.0...v1.809.0) (2026-09-10) diff --git a/backend/.sqlx/query-16c6e24ae06b52feed597a0c3d299107f989d50ea651546e964670cf99fd2de6.json b/backend/.sqlx/query-16c6e24ae06b52feed597a0c3d299107f989d50ea651546e964670cf99fd2de6.json new file mode 100644 index 0000000000..f5bb8e14d1 --- /dev/null +++ b/backend/.sqlx/query-16c6e24ae06b52feed597a0c3d299107f989d50ea651546e964670cf99fd2de6.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO git_sync_ci_test_check\n (workspace_id, poster_workspace_id, head_sha, head_ref, repo_url,\n repo_resource_path, check_run_id,\n created_at, concluded, conclusion, concluded_at, github_posted)\n VALUES ($1, $2, $3, $4, $5, $6, NULL, now(), false, NULL, NULL, false)\n ON CONFLICT (workspace_id, repo_resource_path, head_sha) DO UPDATE SET\n poster_workspace_id = EXCLUDED.poster_workspace_id,\n head_ref = EXCLUDED.head_ref,\n repo_url = EXCLUDED.repo_url,\n check_run_id = NULL,\n created_at = now(),\n concluded = false,\n conclusion = NULL,\n concluded_at = NULL,\n github_posted = false", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "16c6e24ae06b52feed597a0c3d299107f989d50ea651546e964670cf99fd2de6" +} diff --git a/backend/.sqlx/query-18ba139acef81d4de18bf21755fa8605c3851b48bab4964c380538ada93f06a9.json b/backend/.sqlx/query-18ba139acef81d4de18bf21755fa8605c3851b48bab4964c380538ada93f06a9.json new file mode 100644 index 0000000000..8647442530 --- /dev/null +++ b/backend/.sqlx/query-18ba139acef81d4de18bf21755fa8605c3851b48bab4964c380538ada93f06a9.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path, tested_item_path, tested_item_kind, has_wildcard AS \"has_wildcard!\" FROM ci_test_reference WHERE workspace_id = $1 ORDER BY test_script_path, tested_item_kind, tested_item_path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "tested_item_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "tested_item_kind", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "has_wildcard!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "18ba139acef81d4de18bf21755fa8605c3851b48bab4964c380538ada93f06a9" +} diff --git a/backend/.sqlx/query-1b5f6620d35dd74b32ce6325891be02fe144a2da1ebf446b0df92944da791fa7.json b/backend/.sqlx/query-1b5f6620d35dd74b32ce6325891be02fe144a2da1ebf446b0df92944da791fa7.json new file mode 100644 index 0000000000..a78e343072 --- /dev/null +++ b/backend/.sqlx/query-1b5f6620d35dd74b32ce6325891be02fe144a2da1ebf446b0df92944da791fa7.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.username, u.email FROM workspace w JOIN usr u ON u.workspace_id = w.id AND u.email = w.owner WHERE w.id = $1 AND NOT u.disabled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1b5f6620d35dd74b32ce6325891be02fe144a2da1ebf446b0df92944da791fa7" +} diff --git a/backend/.sqlx/query-2ead4c5e0fec64dfdc24431d2f4871ca8667a657dfdfc2fa65e9ff1a3c0d2908.json b/backend/.sqlx/query-2ead4c5e0fec64dfdc24431d2f4871ca8667a657dfdfc2fa65e9ff1a3c0d2908.json new file mode 100644 index 0000000000..99c8262860 --- /dev/null +++ b/backend/.sqlx/query-2ead4c5e0fec64dfdc24431d2f4871ca8667a657dfdfc2fa65e9ff1a3c0d2908.json @@ -0,0 +1,87 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n http_trigger\n SET\n route_path = $1,\n route_path_key = $2,\n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n allowed_origins = $6,\n authentication_resource_path = $7,\n script_path = $8,\n path = $9,\n is_flow = $10,\n mode = $11,\n http_method = $12,\n static_asset_config = $13,\n edited_by = $14,\n permissioned_as = $15,\n request_type = $16,\n authentication_method = $17,\n summary = $18,\n description = $19,\n edited_at = now(),\n is_static_website = $20,\n error_handler_path = $21,\n error_handler_args = $22,\n retry = $23\n WHERE\n workspace_id = $24 AND\n path = $25\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Bool", + "Bool", + "Bool", + "TextArray", + "Varchar", + "Varchar", + "Varchar", + "Bool", + { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + }, + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + { + "Custom": { + "name": "request_type", + "kind": { + "Enum": [ + "sync", + "async", + "sync_sse" + ] + } + } + }, + { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + }, + "Varchar", + "Text", + "Bool", + "Varchar", + "Jsonb", + "Jsonb", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2ead4c5e0fec64dfdc24431d2f4871ca8667a657dfdfc2fa65e9ff1a3c0d2908" +} diff --git a/backend/.sqlx/query-32b4d1fe69fd219a5931fdd63cb2b0cb7770950e5c7fa6128bc9d2b2abcc7f2f.json b/backend/.sqlx/query-32b4d1fe69fd219a5931fdd63cb2b0cb7770950e5c7fa6128bc9d2b2abcc7f2f.json new file mode 100644 index 0000000000..941dc122d0 --- /dev/null +++ b/backend/.sqlx/query-32b4d1fe69fd219a5931fdd63cb2b0cb7770950e5c7fa6128bc9d2b2abcc7f2f.json @@ -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" +} diff --git a/backend/.sqlx/query-35783f52031d7ba14142108480b3599f083dee7415acdbe4410412309dfa2ca1.json b/backend/.sqlx/query-35783f52031d7ba14142108480b3599f083dee7415acdbe4410412309dfa2ca1.json new file mode 100644 index 0000000000..9f9cf036fb --- /dev/null +++ b/backend/.sqlx/query-35783f52031d7ba14142108480b3599f083dee7415acdbe4410412309dfa2ca1.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT r->>'script_path' as \"script_path\"\n FROM workspace_settings ws,\n jsonb_array_elements(\n CASE WHEN jsonb_typeof(ws.git_sync->'repositories') = 'array'\n THEN ws.git_sync->'repositories' END\n ) r\n WHERE ws.workspace_id = $1\n AND r->>'git_repo_resource_path' IN ($2, '$res:' || $2)\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "script_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "35783f52031d7ba14142108480b3599f083dee7415acdbe4410412309dfa2ca1" +} diff --git a/backend/.sqlx/query-3721bd6524ea48a1068ee8013bcc1aeca1b9fe784336fabb71ce13bdb58839da.json b/backend/.sqlx/query-3721bd6524ea48a1068ee8013bcc1aeca1b9fe784336fabb71ce13bdb58839da.json new file mode 100644 index 0000000000..ebaaf0b755 --- /dev/null +++ b/backend/.sqlx/query-3721bd6524ea48a1068ee8013bcc1aeca1b9fe784336fabb71ce13bdb58839da.json @@ -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" +} diff --git a/backend/.sqlx/query-422490f2f91b4d97331e87da135884932eab27f53171c12291cca15a6ec33586.json b/backend/.sqlx/query-422490f2f91b4d97331e87da135884932eab27f53171c12291cca15a6ec33586.json new file mode 100644 index 0000000000..2d0bb40c18 --- /dev/null +++ b/backend/.sqlx/query-422490f2f91b4d97331e87da135884932eab27f53171c12291cca15a6ec33586.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check\n SET concluded = true, github_posted = true, concluded_at = now(),\n conclusion = COALESCE(conclusion, 'failure')\n WHERE (check_run_id IS NULL AND NOT concluded\n AND created_at < now() - make_interval(secs => $1))\n OR (concluded AND NOT github_posted\n AND concluded_at < now() - make_interval(secs => $2))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Float8", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "422490f2f91b4d97331e87da135884932eab27f53171c12291cca15a6ec33586" +} diff --git a/backend/.sqlx/query-45d0e716fa402a63b0bf6877c21c0fa50b81d46845c400d569d6d8e49e503b59.json b/backend/.sqlx/query-45d0e716fa402a63b0bf6877c21c0fa50b81d46845c400d569d6d8e49e503b59.json new file mode 100644 index 0000000000..526f3f5537 --- /dev/null +++ b/backend/.sqlx/query-45d0e716fa402a63b0bf6877c21c0fa50b81d46845c400d569d6d8e49e503b59.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_synced_head SET tests_dispatched_at = NULL\n WHERE workspace_id = $1 AND repo_resource_path = $2 AND branch = $3 AND sha = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "45d0e716fa402a63b0bf6877c21c0fa50b81d46845c400d569d6d8e49e503b59" +} diff --git a/backend/.sqlx/query-47e0f46fddb3ad1c854deb9bdbdcbc2bc7235c63ed4f0a885d412793f3a3a3fc.json b/backend/.sqlx/query-47e0f46fddb3ad1c854deb9bdbdcbc2bc7235c63ed4f0a885d412793f3a3a3fc.json new file mode 100644 index 0000000000..6bec80ccb2 --- /dev/null +++ b/backend/.sqlx/query-47e0f46fddb3ad1c854deb9bdbdcbc2bc7235c63ed4f0a885d412793f3a3a3fc.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n http_trigger\n SET\n wrap_body = $1,\n raw_string = $2,\n allowed_origins = $3,\n authentication_resource_path = $4,\n script_path = $5,\n path = $6,\n is_flow = $7,\n mode = $8,\n http_method = $9,\n static_asset_config = $10,\n edited_by = $11,\n permissioned_as = $12,\n request_type = $13,\n authentication_method = $14,\n summary = $15,\n description = $16,\n edited_at = now(),\n is_static_website = $17,\n error_handler_path = $18,\n error_handler_args = $19,\n retry = $20\n WHERE\n workspace_id = $21 AND\n path = $22\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Bool", + "TextArray", + "Varchar", + "Varchar", + "Varchar", + "Bool", + { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + }, + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + { + "Custom": { + "name": "request_type", + "kind": { + "Enum": [ + "sync", + "async", + "sync_sse" + ] + } + } + }, + { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + }, + "Varchar", + "Text", + "Bool", + "Varchar", + "Jsonb", + "Jsonb", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "47e0f46fddb3ad1c854deb9bdbdcbc2bc7235c63ed4f0a885d412793f3a3a3fc" +} diff --git a/backend/.sqlx/query-4dde939e92f5b8a9cc165c9ea383a456eef081d16c2a58e7262f86d80289c2da.json b/backend/.sqlx/query-4dde939e92f5b8a9cc165c9ea383a456eef081d16c2a58e7262f86d80289c2da.json new file mode 100644 index 0000000000..8c784b664e --- /dev/null +++ b/backend/.sqlx/query-4dde939e92f5b8a9cc165c9ea383a456eef081d16c2a58e7262f86d80289c2da.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_synced_head SET tests_dispatched_at = now()\n WHERE workspace_id = $1 AND repo_resource_path = $2 AND branch = $3 AND sha = $4\n AND (tests_dispatched_at IS NULL\n OR (ci_test_job_ids IS NULL\n AND tests_dispatched_at < now() - make_interval(secs => $5)))\n RETURNING true as \"claimed!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "claimed!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Float8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4dde939e92f5b8a9cc165c9ea383a456eef081d16c2a58e7262f86d80289c2da" +} diff --git a/backend/.sqlx/query-4e4b31e97f0cc946f26cc0faf9a09de2846c43ee48f047e0f2ea9ee7a6502c81.json b/backend/.sqlx/query-4e4b31e97f0cc946f26cc0faf9a09de2846c43ee48f047e0f2ea9ee7a6502c81.json new file mode 100644 index 0000000000..c8b8b94da4 --- /dev/null +++ b/backend/.sqlx/query-4e4b31e97f0cc946f26cc0faf9a09de2846c43ee48f047e0f2ea9ee7a6502c81.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (\n SELECT h.sha FROM git_sync_synced_head h\n WHERE h.workspace_id = $1 AND h.repo_resource_path = $4 AND h.branch = $3\n ORDER BY h.synced_at DESC LIMIT 1\n ) = $2 AND NOT EXISTS (\n SELECT 1\n FROM v2_job_queue q\n JOIN v2_job j ON j.id = q.id\n WHERE q.workspace_id = $1\n AND j.kind = 'deploymentcallback'\n AND j.args->'__git_sync_auto_pull'->>'branch' = $3\n AND j.args->'__git_sync_auto_pull'->>'repo_resource_path'\n IN ($4, '$res:' || $4)\n ) AND NOT EXISTS (\n SELECT 1\n FROM v2_job_queue q\n JOIN v2_job j ON j.id = q.id\n WHERE q.workspace_id = $1\n AND j.kind IN ('dependencies', 'flowdependencies', 'appdependencies')\n ) as \"ready\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "ready", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4e4b31e97f0cc946f26cc0faf9a09de2846c43ee48f047e0f2ea9ee7a6502c81" +} diff --git a/backend/.sqlx/query-5fcaf17e24fa00ffafdc5f0f425fe4c1c745457d463d8ea61627ebe61ba8ab2c.json b/backend/.sqlx/query-5fcaf17e24fa00ffafdc5f0f425fe4c1c745457d463d8ea61627ebe61ba8ab2c.json new file mode 100644 index 0000000000..53a3b04d6b --- /dev/null +++ b/backend/.sqlx/query-5fcaf17e24fa00ffafdc5f0f425fe4c1c745457d463d8ea61627ebe61ba8ab2c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_synced_head SET ci_test_job_ids = NULL, tests_dispatched_at = NULL\n WHERE workspace_id = $1 AND repo_resource_path = $2 AND branch = $3 AND sha = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5fcaf17e24fa00ffafdc5f0f425fe4c1c745457d463d8ea61627ebe61ba8ab2c" +} diff --git a/backend/.sqlx/query-61caaa5c4ae62f618ba18f36330bbf58ff58e8c448874ffc9ca466165e545878.json b/backend/.sqlx/query-61caaa5c4ae62f618ba18f36330bbf58ff58e8c448874ffc9ca466165e545878.json new file mode 100644 index 0000000000..f50b4b743f --- /dev/null +++ b/backend/.sqlx/query-61caaa5c4ae62f618ba18f36330bbf58ff58e8c448874ffc9ca466165e545878.json @@ -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" +} diff --git a/backend/.sqlx/query-6e3cd83ad7eef0dddacf9359f662193e094ca44642342778fb4281a711263385.json b/backend/.sqlx/query-6e3cd83ad7eef0dddacf9359f662193e094ca44642342778fb4281a711263385.json new file mode 100644 index 0000000000..427ba00075 --- /dev/null +++ b/backend/.sqlx/query-6e3cd83ad7eef0dddacf9359f662193e094ca44642342778fb4281a711263385.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_synced_head SET ci_test_job_ids = $5\n WHERE workspace_id = $1 AND repo_resource_path = $2 AND branch = $3 AND sha = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "6e3cd83ad7eef0dddacf9359f662193e094ca44642342778fb4281a711263385" +} diff --git a/backend/.sqlx/query-79d3bbd278c34734ce5babc3be852898bb83047f3563f789753e8fec030d301f.json b/backend/.sqlx/query-79d3bbd278c34734ce5babc3be852898bb83047f3563f789753e8fec030d301f.json new file mode 100644 index 0000000000..98aa0d9367 --- /dev/null +++ b/backend/.sqlx/query-79d3bbd278c34734ce5babc3be852898bb83047f3563f789753e8fec030d301f.json @@ -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" +} diff --git a/backend/.sqlx/query-7a0ddb6821d8f628bcf85f786e5864e09c7a5a421ba99647570c2b557d53aa51.json b/backend/.sqlx/query-7a0ddb6821d8f628bcf85f786e5864e09c7a5a421ba99647570c2b557d53aa51.json new file mode 100644 index 0000000000..b7cccbfad4 --- /dev/null +++ b/backend/.sqlx/query-7a0ddb6821d8f628bcf85f786e5864e09c7a5a421ba99647570c2b557d53aa51.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check SET github_posted = true\n WHERE workspace_id = $1 AND repo_resource_path = $4 AND head_sha = $2\n AND check_run_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7a0ddb6821d8f628bcf85f786e5864e09c7a5a421ba99647570c2b557d53aa51" +} diff --git a/backend/.sqlx/query-89a7f413f6f37aeb7e777faeebb0c4e1612928737a5fb2b4ff1336d8827f788b.json b/backend/.sqlx/query-89a7f413f6f37aeb7e777faeebb0c4e1612928737a5fb2b4ff1336d8827f788b.json new file mode 100644 index 0000000000..e0d369fd12 --- /dev/null +++ b/backend/.sqlx/query-89a7f413f6f37aeb7e777faeebb0c4e1612928737a5fb2b4ff1336d8827f788b.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check\n SET concluded = true, conclusion = $3, concluded_at = now()\n WHERE workspace_id = $1 AND repo_resource_path = $4 AND head_sha = $2 AND NOT concluded\n RETURNING check_run_id, poster_workspace_id, repo_url", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "check_run_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "poster_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "repo_url", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "89a7f413f6f37aeb7e777faeebb0c4e1612928737a5fb2b4ff1336d8827f788b" +} diff --git a/backend/.sqlx/query-8efe5509034327c202cb3fdd409ce00cc1dbd4921a9ccee8f906515286190b8f.json b/backend/.sqlx/query-8efe5509034327c202cb3fdd409ce00cc1dbd4921a9ccee8f906515286190b8f.json new file mode 100644 index 0000000000..db3362bf0c --- /dev/null +++ b/backend/.sqlx/query-8efe5509034327c202cb3fdd409ce00cc1dbd4921a9ccee8f906515286190b8f.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ci_test_job_ids\n FROM git_sync_synced_head\n WHERE workspace_id = $1 AND repo_resource_path = $2 AND branch = $3 AND sha = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "ci_test_job_ids", + "type_info": "UuidArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "8efe5509034327c202cb3fdd409ce00cc1dbd4921a9ccee8f906515286190b8f" +} diff --git a/backend/.sqlx/query-98b036be15cbd5efbaf2420feb56dd175aeab87f6377f6ac7b51956ce6d5f039.json b/backend/.sqlx/query-98b036be15cbd5efbaf2420feb56dd175aeab87f6377f6ac7b51956ce6d5f039.json new file mode 100644 index 0000000000..b63a9d294e --- /dev/null +++ b/backend/.sqlx/query-98b036be15cbd5efbaf2420feb56dd175aeab87f6377f6ac7b51956ce6d5f039.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_synced_head SET tests_dispatched_at = NULL\n WHERE workspace_id = $1 AND repo_resource_path = $2 AND branch = $3 AND sha = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "98b036be15cbd5efbaf2420feb56dd175aeab87f6377f6ac7b51956ce6d5f039" +} diff --git a/backend/.sqlx/query-98cb765a480dac8a27ecad5df26c26f3dd6ff9aa87293ca671be32fd1305a73a.json b/backend/.sqlx/query-98cb765a480dac8a27ecad5df26c26f3dd6ff9aa87293ca671be32fd1305a73a.json new file mode 100644 index 0000000000..a4a692fd4b --- /dev/null +++ b/backend/.sqlx/query-98cb765a480dac8a27ecad5df26c26f3dd6ff9aa87293ca671be32fd1305a73a.json @@ -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" +} diff --git a/backend/.sqlx/query-99194b850cb30d174c8d99303f6ed693e011a89aefb3a0e239630e6950dca5cd.json b/backend/.sqlx/query-99194b850cb30d174c8d99303f6ed693e011a89aefb3a0e239630e6950dca5cd.json new file mode 100644 index 0000000000..9960ba85c9 --- /dev/null +++ b/backend/.sqlx/query-99194b850cb30d174c8d99303f6ed693e011a89aefb3a0e239630e6950dca5cd.json @@ -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" +} diff --git a/backend/.sqlx/query-a7b6731427d51eb34744ca6a39e30d02949f149901ec95323b5725911944df4d.json b/backend/.sqlx/query-a7b6731427d51eb34744ca6a39e30d02949f149901ec95323b5725911944df4d.json new file mode 100644 index 0000000000..fa43d27ed8 --- /dev/null +++ b/backend/.sqlx/query-a7b6731427d51eb34744ca6a39e30d02949f149901ec95323b5725911944df4d.json @@ -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" +} diff --git a/backend/.sqlx/query-a7d5a7b6b3bb88f5f7926da577f2cc25020b11882fb359b136466c82b040f8a1.json b/backend/.sqlx/query-a7d5a7b6b3bb88f5f7926da577f2cc25020b11882fb359b136466c82b040f8a1.json new file mode 100644 index 0000000000..26301c39b2 --- /dev/null +++ b/backend/.sqlx/query-a7d5a7b6b3bb88f5f7926da577f2cc25020b11882fb359b136466c82b040f8a1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM git_sync_ci_test_check c\n WHERE c.concluded AND c.github_posted\n AND c.concluded_at < now() - make_interval(secs => $1)\n AND NOT EXISTS (\n SELECT 1 FROM git_sync_synced_head h\n WHERE h.workspace_id = c.workspace_id\n AND h.repo_resource_path = c.repo_resource_path\n AND h.sha = c.head_sha\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Float8" + ] + }, + "nullable": [] + }, + "hash": "a7d5a7b6b3bb88f5f7926da577f2cc25020b11882fb359b136466c82b040f8a1" +} diff --git a/backend/.sqlx/query-a970bbf4d3d064614bc47d438a0447eff928c4518fd2f6800145bf1211086352.json b/backend/.sqlx/query-a970bbf4d3d064614bc47d438a0447eff928c4518fd2f6800145bf1211086352.json new file mode 100644 index 0000000000..258ee9eaa7 --- /dev/null +++ b/backend/.sqlx/query-a970bbf4d3d064614bc47d438a0447eff928c4518fd2f6800145bf1211086352.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT repo_resource_path, head_sha FROM git_sync_ci_test_check\n WHERE workspace_id = $1 AND NOT concluded", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "repo_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "head_sha", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a970bbf4d3d064614bc47d438a0447eff928c4518fd2f6800145bf1211086352" +} diff --git a/backend/.sqlx/query-ab752dd133b20103800554b3f6622e8a9147d36a4b56ebd30a1aaa960156b598.json b/backend/.sqlx/query-ab752dd133b20103800554b3f6622e8a9147d36a4b56ebd30a1aaa960156b598.json new file mode 100644 index 0000000000..5a9e5da58e --- /dev/null +++ b/backend/.sqlx/query-ab752dd133b20103800554b3f6622e8a9147d36a4b56ebd30a1aaa960156b598.json @@ -0,0 +1,86 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO http_trigger (\n workspace_id,\n path,\n route_path,\n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n allowed_origins,\n script_path,\n summary,\n description,\n is_flow,\n mode,\n request_type,\n authentication_method,\n http_method,\n static_asset_config,\n edited_by,\n permissioned_as,\n edited_at,\n is_static_website,\n error_handler_path,\n error_handler_args,\n retry\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, now(), $21, $22, $23, $24\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Bool", + "Bool", + "TextArray", + "Varchar", + "Varchar", + "Text", + "Bool", + { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + }, + { + "Custom": { + "name": "request_type", + "kind": { + "Enum": [ + "sync", + "async", + "sync_sse" + ] + } + } + }, + { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + }, + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "ab752dd133b20103800554b3f6622e8a9147d36a4b56ebd30a1aaa960156b598" +} diff --git a/backend/.sqlx/query-b7c72ecebf6818d4e60a02edb986c920aa2e14365e63ef8c28c7f65da7c6c9ab.json b/backend/.sqlx/query-b7c72ecebf6818d4e60a02edb986c920aa2e14365e63ef8c28c7f65da7c6c9ab.json new file mode 100644 index 0000000000..2991d30d1c --- /dev/null +++ b/backend/.sqlx/query-b7c72ecebf6818d4e60a02edb986c920aa2e14365e63ef8c28c7f65da7c6c9ab.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE git_sync_ci_test_check\n SET github_posted = github_posted\n AND check_run_id IS NOT DISTINCT FROM GREATEST(check_run_id, $3),\n check_run_id = GREATEST(check_run_id, $3)\n WHERE workspace_id = $1 AND repo_resource_path = $4 AND head_sha = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b7c72ecebf6818d4e60a02edb986c920aa2e14365e63ef8c28c7f65da7c6c9ab" +} diff --git a/backend/.sqlx/query-bafee32cbff8bb7fff26a241d9ad203ea689c1e4b094a2c90eb90c8e4b6e0dff.json b/backend/.sqlx/query-bafee32cbff8bb7fff26a241d9ad203ea689c1e4b094a2c90eb90c8e4b6e0dff.json new file mode 100644 index 0000000000..31c628c1a8 --- /dev/null +++ b/backend/.sqlx/query-bafee32cbff8bb7fff26a241d9ad203ea689c1e4b094a2c90eb90c8e4b6e0dff.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1 FROM git_sync_ci_test_check\n WHERE poster_workspace_id = $1 AND repo_resource_path = $2 AND head_sha = $3\n ) as \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bafee32cbff8bb7fff26a241d9ad203ea689c1e4b094a2c90eb90c8e4b6e0dff" +} diff --git a/backend/.sqlx/query-bff72874d1fdee7d572e2677aea1dede87b6793f92af884a023253a0ffc3a905.json b/backend/.sqlx/query-bff72874d1fdee7d572e2677aea1dede87b6793f92af884a023253a0ffc3a905.json new file mode 100644 index 0000000000..d64cf09687 --- /dev/null +++ b/backend/.sqlx/query-bff72874d1fdee7d572e2677aea1dede87b6793f92af884a023253a0ffc3a905.json @@ -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" +} diff --git a/backend/.sqlx/query-c1976ac63f5d763b2a747ff92f5ef9db3a0579c889bcae4e8bf33467ce5cebd1.json b/backend/.sqlx/query-c1976ac63f5d763b2a747ff92f5ef9db3a0579c889bcae4e8bf33467ce5cebd1.json new file mode 100644 index 0000000000..ed6a4f9b5e --- /dev/null +++ b/backend/.sqlx/query-c1976ac63f5d763b2a747ff92f5ef9db3a0579c889bcae4e8bf33467ce5cebd1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO http_trigger (\n path, route_path, route_path_key, script_path, is_flow, workspace_id,\n edited_by, edited_at, extra_perms, authentication_method, http_method,\n static_asset_config, is_static_website, workspaced_route, wrap_body,\n raw_string, allowed_origins, authentication_resource_path, summary, description,\n error_handler_path, error_handler_args, retry, request_type, mode,\n permissioned_as, labels\n )\n SELECT\n path, route_path, route_path_key, script_path, is_flow, $1,\n edited_by, edited_at, extra_perms, authentication_method, http_method,\n static_asset_config, is_static_website, workspaced_route, wrap_body,\n raw_string, allowed_origins, authentication_resource_path, summary, description,\n error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE,\n permissioned_as, labels\n FROM http_trigger\n WHERE workspace_id = $2\n AND (workspaced_route IS TRUE OR $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "c1976ac63f5d763b2a747ff92f5ef9db3a0579c889bcae4e8bf33467ce5cebd1" +} diff --git a/backend/.sqlx/query-c38a1cf8d2a8fd89008a98f03ab87a438b619eb1919c028c6c80e972b4ae438d.json b/backend/.sqlx/query-c38a1cf8d2a8fd89008a98f03ab87a438b619eb1919c028c6c80e972b4ae438d.json new file mode 100644 index 0000000000..c904dc72fd --- /dev/null +++ b/backend/.sqlx/query-c38a1cf8d2a8fd89008a98f03ab87a438b619eb1919c028c6c80e972b4ae438d.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"count!\"\n FROM git_sync_synced_head h\n JOIN v2_job_completed pc ON pc.id = h.job_id\n JOIN v2_job j ON j.workspace_id = h.workspace_id\n AND j.kind IN ('dependencies', 'flowdependencies', 'appdependencies')\n AND j.created_at >= pc.started_at\n AND j.created_at <= COALESCE(h.tests_dispatched_at, now())\n JOIN v2_job_completed c ON c.id = j.id AND c.status IN ('failure', 'canceled')\n WHERE h.workspace_id = $1 AND h.repo_resource_path = $4\n AND h.branch = $3 AND h.sha = $2\n AND h.source = 'pull'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c38a1cf8d2a8fd89008a98f03ab87a438b619eb1919c028c6c80e972b4ae438d" +} diff --git a/backend/.sqlx/query-c7a78d3db99e7f709479c9520471eaf40862b464af7113d2632c626e43c35c04.json b/backend/.sqlx/query-c7a78d3db99e7f709479c9520471eaf40862b464af7113d2632c626e43c35c04.json new file mode 100644 index 0000000000..7bc28f0f39 --- /dev/null +++ b/backend/.sqlx/query-c7a78d3db99e7f709479c9520471eaf40862b464af7113d2632c626e43c35c04.json @@ -0,0 +1,185 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n path,\n script_path,\n is_flow,\n route_path,\n authentication_resource_path,\n workspace_id,\n request_type AS \"request_type: _\",\n authentication_method AS \"authentication_method: _\",\n edited_by,\n permissioned_as,\n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n allowed_origins,\n workspaced_route,\n is_static_website,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\",\n mode as \"mode: _\"\n FROM\n http_trigger\n WHERE\n http_method = $1 AND\n (mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "route_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "authentication_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "request_type: _", + "type_info": { + "Custom": { + "name": "request_type", + "kind": { + "Enum": [ + "sync", + "async", + "sync_sse" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "authentication_method: _", + "type_info": { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "static_asset_config: _", + "type_info": "Jsonb" + }, + { + "ordinal": 11, + "name": "wrap_body", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "raw_string", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "allowed_origins", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "workspaced_route", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "is_static_website", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "error_handler_path", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "error_handler_args: _", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "retry: _", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "mode: _", + "type_info": { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "c7a78d3db99e7f709479c9520471eaf40862b464af7113d2632c626e43c35c04" +} diff --git a/backend/.sqlx/query-cfbe6784b3d108f935ba884fe2d3b36afde836fbc6db046279b015bea6f70201.json b/backend/.sqlx/query-cfbe6784b3d108f935ba884fe2d3b36afde836fbc6db046279b015bea6f70201.json new file mode 100644 index 0000000000..cb87d3b3f6 --- /dev/null +++ b/backend/.sqlx/query-cfbe6784b3d108f935ba884fe2d3b36afde836fbc6db046279b015bea6f70201.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT args->>'repo_url_resource_path' FROM v2_job WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cfbe6784b3d108f935ba884fe2d3b36afde836fbc6db046279b015bea6f70201" +} diff --git a/backend/.sqlx/query-d599e8058e96f3708cf6af2cc1a2ea3d912ec703687f6ce52d2419a410b1599e.json b/backend/.sqlx/query-d599e8058e96f3708cf6af2cc1a2ea3d912ec703687f6ce52d2419a410b1599e.json new file mode 100644 index 0000000000..30cc0e7092 --- /dev/null +++ b/backend/.sqlx/query-d599e8058e96f3708cf6af2cc1a2ea3d912ec703687f6ce52d2419a410b1599e.json @@ -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" +} diff --git a/backend/.sqlx/query-d644b9cd3407e58f235cc2e97558257c07785c8b3123f13d59ee361b4ee0bc0a.json b/backend/.sqlx/query-d644b9cd3407e58f235cc2e97558257c07785c8b3123f13d59ee361b4ee0bc0a.json new file mode 100644 index 0000000000..70fd38193c --- /dev/null +++ b/backend/.sqlx/query-d644b9cd3407e58f235cc2e97558257c07785c8b3123f13d59ee361b4ee0bc0a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND deleted = false AND archived = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d644b9cd3407e58f235cc2e97558257c07785c8b3123f13d59ee361b4ee0bc0a" +} diff --git a/backend/.sqlx/query-d88b1c445acc5375e9c543dcec81059d0e7018a6cea79ba743693098d223edf5.json b/backend/.sqlx/query-d88b1c445acc5375e9c543dcec81059d0e7018a6cea79ba743693098d223edf5.json new file mode 100644 index 0000000000..b0c2d95175 --- /dev/null +++ b/backend/.sqlx/query-d88b1c445acc5375e9c543dcec81059d0e7018a6cea79ba743693098d223edf5.json @@ -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" +} diff --git a/backend/.sqlx/query-dada076fe622e9902606bf95f9e6df004f8cb7091588309a1f484b91fe7183fc.json b/backend/.sqlx/query-dada076fe622e9902606bf95f9e6df004f8cb7091588309a1f484b91fe7183fc.json new file mode 100644 index 0000000000..669e78201d --- /dev/null +++ b/backend/.sqlx/query-dada076fe622e9902606bf95f9e6df004f8cb7091588309a1f484b91fe7183fc.json @@ -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" +} diff --git a/backend/.sqlx/query-dfffd6573a1eab11c0515805f85b95a942f71651a4f34e671f25ba6816a506c8.json b/backend/.sqlx/query-dfffd6573a1eab11c0515805f85b95a942f71651a4f34e671f25ba6816a506c8.json new file mode 100644 index 0000000000..9129b657d3 --- /dev/null +++ b/backend/.sqlx/query-dfffd6573a1eab11c0515805f85b95a942f71651a4f34e671f25ba6816a506c8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"count!\" FROM v2_job WHERE id = ANY($1::uuid[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "dfffd6573a1eab11c0515805f85b95a942f71651a4f34e671f25ba6816a506c8" +} diff --git a/backend/.sqlx/query-e64ec4941cbbee016c14d958b7220ddfc414c7e741a171fb80673c23644e3619.json b/backend/.sqlx/query-e64ec4941cbbee016c14d958b7220ddfc414c7e741a171fb80673c23644e3619.json new file mode 100644 index 0000000000..cf4d577dbb --- /dev/null +++ b/backend/.sqlx/query-e64ec4941cbbee016c14d958b7220ddfc414c7e741a171fb80673c23644e3619.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, poster_workspace_id, head_sha, repo_url, repo_resource_path,\n check_run_id\n FROM git_sync_ci_test_check\n WHERE NOT concluded OR NOT github_posted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "poster_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "head_sha", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "repo_url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "repo_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "check_run_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "e64ec4941cbbee016c14d958b7220ddfc414c7e741a171fb80673c23644e3619" +} diff --git a/backend/.sqlx/query-e6f2a6fa47bf3b5c774d6bc6060ca5a99addffab3dd44529ce9f40de115b0a3c.json b/backend/.sqlx/query-e6f2a6fa47bf3b5c774d6bc6060ca5a99addffab3dd44529ce9f40de115b0a3c.json new file mode 100644 index 0000000000..f8c42256bb --- /dev/null +++ b/backend/.sqlx/query-e6f2a6fa47bf3b5c774d6bc6060ca5a99addffab3dd44529ce9f40de115b0a3c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM git_sync_synced_head h\n WHERE h.synced_at < now() - make_interval(secs => $1)\n AND EXISTS (\n SELECT 1 FROM git_sync_synced_head n\n WHERE n.workspace_id = h.workspace_id\n AND n.repo_resource_path = h.repo_resource_path\n AND n.branch = h.branch\n AND n.synced_at > h.synced_at\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Float8" + ] + }, + "nullable": [] + }, + "hash": "e6f2a6fa47bf3b5c774d6bc6060ca5a99addffab3dd44529ce9f40de115b0a3c" +} diff --git a/backend/.sqlx/query-ea397add5eb6555457883e5b6bdc67efbe6b0559adb891dba65d8b8e1430f357.json b/backend/.sqlx/query-ea397add5eb6555457883e5b6bdc67efbe6b0559adb891dba65d8b8e1430f357.json new file mode 100644 index 0000000000..5d0e560ced --- /dev/null +++ b/backend/.sqlx/query-ea397add5eb6555457883e5b6bdc67efbe6b0559adb891dba65d8b8e1430f357.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(jc.status::text, 'running') as \"status!\"\n FROM unnest($1::uuid[]) AS run(id)\n LEFT JOIN v2_job_completed jc ON jc.id = run.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ea397add5eb6555457883e5b6bdc67efbe6b0559adb891dba65d8b8e1430f357" +} diff --git a/backend/.sqlx/query-f1282393a95b499f1a9fce5939205879d507b9697eed7e5e232f8e6cb95c2bd0.json b/backend/.sqlx/query-f1282393a95b499f1a9fce5939205879d507b9697eed7e5e232f8e6cb95c2bd0.json new file mode 100644 index 0000000000..7020c21b0d --- /dev/null +++ b/backend/.sqlx/query-f1282393a95b499f1a9fce5939205879d507b9697eed7e5e232f8e6cb95c2bd0.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO git_sync_synced_head\n (workspace_id, repo_resource_path, branch, sha, source, job_id)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (workspace_id, repo_resource_path, branch, sha)\n DO UPDATE SET source = EXCLUDED.source, job_id = EXCLUDED.job_id, synced_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f1282393a95b499f1a9fce5939205879d507b9697eed7e5e232f8e6cb95c2bd0" +} diff --git a/backend/.sqlx/query-f6a2a8fbc22c69fd5da86626372f84d0ec7b6cb9375e30b10415605cba9b2fcb.json b/backend/.sqlx/query-f6a2a8fbc22c69fd5da86626372f84d0ec7b6cb9375e30b10415605cba9b2fcb.json new file mode 100644 index 0000000000..dda976de02 --- /dev/null +++ b/backend/.sqlx/query-f6a2a8fbc22c69fd5da86626372f84d0ec7b6cb9375e30b10415605cba9b2fcb.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT repo_url, check_run_id, poster_workspace_id, conclusion,\n created_at, concluded, github_posted, head_ref\n FROM git_sync_ci_test_check\n WHERE workspace_id = $1 AND repo_resource_path = $3 AND head_sha = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "repo_url", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "check_run_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "poster_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "conclusion", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "concluded", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "github_posted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "head_ref", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + true, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "f6a2a8fbc22c69fd5da86626372f84d0ec7b6cb9375e30b10415605cba9b2fcb" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2ed37bb78e..2d88b2a69f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", @@ -9591,7 +9592,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.14+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -11801,9 +11802,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ "serde", ] @@ -13607,9 +13608,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.14+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", @@ -14791,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-nats", @@ -14879,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.809.0" +version = "1.811.1" dependencies = [ "async-stream", "async-trait", @@ -14912,7 +14913,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14925,7 +14926,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "argon2", @@ -15065,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15088,7 +15089,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15105,7 +15106,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15131,7 +15132,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.809.0" +version = "1.811.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -15141,7 +15142,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15158,7 +15159,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.809.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.809.0" +version = "1.811.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15203,7 +15204,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15219,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15241,7 +15242,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15262,7 +15263,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15276,7 +15277,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-nats", @@ -15311,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15336,7 +15337,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15364,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15386,7 +15387,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15406,7 +15407,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15444,7 +15445,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15472,7 +15473,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.809.0" +version = "1.811.1" dependencies = [ "lazy_static", "serde", @@ -15484,7 +15485,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.809.0" +version = "1.811.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15508,7 +15509,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15522,7 +15523,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.809.0" +version = "1.811.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15557,7 +15558,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.809.0" +version = "1.811.1" dependencies = [ "chrono", "lazy_static", @@ -15571,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15590,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.809.0" +version = "1.811.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15697,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.809.0" +version = "1.811.1" dependencies = [ "chrono", "futures", @@ -15717,7 +15718,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.809.0" +version = "1.811.1" dependencies = [ "regex", "serde", @@ -15727,12 +15728,13 @@ dependencies = [ "tracing", "uuid", "windmill-common", + "windmill-dep-map", "windmill-queue", ] [[package]] name = "windmill-indexer" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15759,7 +15761,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "futures", @@ -15776,7 +15778,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.809.0" +version = "1.811.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15792,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -15813,7 +15815,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -15844,7 +15846,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "arc-swap", @@ -15869,7 +15871,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-stream", @@ -15904,7 +15906,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "futures", @@ -15922,7 +15924,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.809.0" +version = "1.811.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15931,7 +15933,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -15943,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde_json", @@ -15955,7 +15957,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "gosyn", @@ -15967,7 +15969,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -15979,7 +15981,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde_json", @@ -15991,7 +15993,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "nu-parser", @@ -16002,7 +16004,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16013,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16025,7 +16027,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16036,7 +16038,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-recursion", @@ -16058,7 +16060,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde_json", @@ -16070,7 +16072,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -16084,7 +16086,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16101,7 +16103,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -16114,7 +16116,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde", @@ -16126,7 +16128,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -16144,7 +16146,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16160,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16176,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -16190,7 +16192,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-recursion", @@ -16229,7 +16231,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "const_format", @@ -16269,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.809.0" +version = "1.811.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16280,7 +16282,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-recursion", @@ -16315,7 +16317,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16339,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16372,7 +16374,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16399,7 +16401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16432,7 +16434,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16452,7 +16454,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16486,7 +16488,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16522,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16545,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16569,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-nats", @@ -16593,7 +16595,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16628,7 +16630,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16656,7 +16658,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-trait", @@ -16681,7 +16683,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16700,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-once-cell", @@ -16818,7 +16820,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.809.0" +version = "1.811.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index be7ab2d192..d960a0d3e8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.809.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.809.0" +version = "1.811.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 0ec4f8ac2d..685c6cb329 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3c586cddf9be56ca499e0c269906cb0dce290e28 +cfaa496718f8add7dbab416a5fa82632c3a369b5 diff --git a/backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql b/backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql new file mode 100644 index 0000000000..b21e2bb474 --- /dev/null +++ b/backend/migrations/20260714142042_add_git_sync_ci_test_check.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS git_sync_ci_test_check; diff --git a/backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql b/backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql new file mode 100644 index 0000000000..59fabb890c --- /dev/null +++ b/backend/migrations/20260714142042_add_git_sync_ci_test_check.up.sql @@ -0,0 +1,39 @@ +-- One "Windmill CI tests" GitHub check run per (fork workspace, repository, PR head commit): +-- the pull_request webhook opens the check in_progress and it is concluded once +-- the fork's CI tests settle, so the results can gate a GitHub PR. +CREATE TABLE git_sync_ci_test_check ( + -- The fork workspace whose CI tests gate the PR: keys the row, and its `ci_test` + -- jobs are what the check reflects. + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + head_sha VARCHAR(64) NOT NULL, + -- The PR's head branch: the check waits until the fork's synced state for this + -- branch (written by its pushes and pulls alike) names `head_sha`. + head_ref VARCHAR(255) NOT NULL, + -- The workspace whose git host credential posts the check: the one that received + -- the pull request webhook (the parent owning the repo hook). + poster_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + repo_url TEXT NOT NULL, + -- The fork's copy of the repository resource: keys the synced-head lookup, since a + -- fork syncing two repositories names its branch identically in both. + repo_resource_path VARCHAR(255) NOT NULL, + -- NULL when the GitHub check-run creation failed; the poller retries the create. + check_run_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + concluded BOOLEAN NOT NULL DEFAULT false, + conclusion TEXT, + concluded_at TIMESTAMPTZ, + -- Decoupled from `concluded` so a failed check-run PATCH is retried by the + -- poller instead of hanging a required check on GitHub. + github_posted BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (workspace_id, repo_resource_path, head_sha) +); + +-- Rows still needing action (create retry, conclusion, timeout, delivery retry). +-- A row drops out only once it is both concluded and delivered to GitHub, so the +-- per-job conclusion hook and the poller sweeper both scan a small live set. +CREATE INDEX idx_git_sync_ci_test_check_pending + ON git_sync_ci_test_check (workspace_id) + WHERE NOT concluded OR NOT github_posted; + +GRANT ALL ON git_sync_ci_test_check TO windmill_user; +GRANT ALL ON git_sync_ci_test_check TO windmill_admin; diff --git a/backend/migrations/20260825105019_http_trigger_allowed_origins.down.sql b/backend/migrations/20260825105019_http_trigger_allowed_origins.down.sql new file mode 100644 index 0000000000..bee3e6034c --- /dev/null +++ b/backend/migrations/20260825105019_http_trigger_allowed_origins.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER TABLE http_trigger DROP COLUMN allowed_origins; diff --git a/backend/migrations/20260825105019_http_trigger_allowed_origins.up.sql b/backend/migrations/20260825105019_http_trigger_allowed_origins.up.sql new file mode 100644 index 0000000000..444fc46535 --- /dev/null +++ b/backend/migrations/20260825105019_http_trigger_allowed_origins.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TABLE http_trigger ADD COLUMN allowed_origins TEXT[]; diff --git a/backend/migrations/20260909092950_add_git_sync_synced_head.down.sql b/backend/migrations/20260909092950_add_git_sync_synced_head.down.sql new file mode 100644 index 0000000000..a089085444 --- /dev/null +++ b/backend/migrations/20260909092950_add_git_sync_synced_head.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS git_sync_synced_head; diff --git a/backend/migrations/20260909092950_add_git_sync_synced_head.up.sql b/backend/migrations/20260909092950_add_git_sync_synced_head.up.sql new file mode 100644 index 0000000000..a0c81d2671 --- /dev/null +++ b/backend/migrations/20260909092950_add_git_sync_synced_head.up.sql @@ -0,0 +1,24 @@ +-- One row per commit a workspace has come to reflect on a branch, written when a +-- pull of that commit succeeds or a deploy push produces it. The "Windmill CI +-- tests" PR check reads it to know when a workspace reflects a PR head, and +-- records the head's CI test runs on it. Kept apart from `workspace_settings.git_sync.auto_pull.last_synced_sha`, +-- which decides whether the next poll pulls and is client-round-tripped settings. +CREATE TABLE git_sync_synced_head ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + -- Repository resource path without its `$res:` prefix. + repo_resource_path VARCHAR(255) NOT NULL, + branch VARCHAR(255) NOT NULL, + sha VARCHAR(64) NOT NULL, + -- 'pull' rows name the pull job; 'push' rows the deploy push job. + source VARCHAR(4) NOT NULL CHECK (source IN ('pull', 'push')), + job_id UUID, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- The head's own CI test suite, dispatched once the workspace reflects it and its + -- dependency jobs settled: the "Windmill CI tests" check reads exactly these runs. + tests_dispatched_at TIMESTAMPTZ, + ci_test_job_ids UUID[], + PRIMARY KEY (workspace_id, repo_resource_path, branch, sha) +); + +GRANT ALL ON git_sync_synced_head TO windmill_user; +GRANT ALL ON git_sync_synced_head TO windmill_admin; diff --git a/backend/migrations/20260911085221_backfill_app_policy_on_behalf_of.down.sql b/backend/migrations/20260911085221_backfill_app_policy_on_behalf_of.down.sql new file mode 100644 index 0000000000..f615c55463 --- /dev/null +++ b/backend/migrations/20260911085221_backfill_app_policy_on_behalf_of.down.sql @@ -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; diff --git a/backend/migrations/20260911085221_backfill_app_policy_on_behalf_of.up.sql b/backend/migrations/20260911085221_backfill_app_policy_on_behalf_of.up.sql new file mode 100644 index 0000000000..fe97d7529c --- /dev/null +++ b/backend/migrations/20260911085221_backfill_app_policy_on_behalf_of.up.sql @@ -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); diff --git a/backend/migrations/20260911085230_notify_user_email_change.down.sql b/backend/migrations/20260911085230_notify_user_email_change.down.sql new file mode 100644 index 0000000000..7f6f494115 --- /dev/null +++ b/backend/migrations/20260911085230_notify_user_email_change.down.sql @@ -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(); diff --git a/backend/migrations/20260911085230_notify_user_email_change.up.sql b/backend/migrations/20260911085230_notify_user_email_change.up.sql new file mode 100644 index 0000000000..f4e4a818f9 --- /dev/null +++ b/backend/migrations/20260911085230_notify_user_email_change.up.sql @@ -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(); diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 3f8c265a4a..bc2498a943 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -241,6 +241,7 @@ } }, "snowflake_oauth": { + "resource_fields": ["database", "warehouse", "role", "schema"], "connect_config_template": { "display_name": "Snowflake", "label": "Snowflake Account Identifier", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 5c6ad27103..bf2f75120e 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.809.0" +version = "1.811.1" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.809.0" +version = "1.811.1" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.809.0" +version = "1.811.1" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.809.0" +version = "1.811.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f91b32e726..e7ee625be9 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.809.0" +version = "1.811.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index 118795bd17..7f2ce3cd82 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -46,7 +46,8 @@ use windmill_common::{ CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, @@ -135,7 +136,8 @@ use crate::monitor::{ reload_bun_install_min_release_age_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alert_mute_zombie_job_restart_setting, reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, - reload_extra_pip_index_url_setting, reload_http_route_workspaced_route_setting, + reload_extra_pip_index_url_setting, reload_http_route_default_allowed_origins_setting, + reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_instance_events_webhook_setting, reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, @@ -1913,6 +1915,17 @@ async fn process_notify_event( ); windmill_api::auth::invalidate_token_from_cache(payload); } + "notify_user_email_change" => { + // `:`, or `*:` 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 ids can't contain ':'. if server_mode { @@ -2134,6 +2147,11 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload app workspaced route setting"); } } + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING => { + if let Err(e) = reload_http_route_default_allowed_origins_setting(db).await { + tracing::error!(error = %e, "Could not reload http route default allowed origins setting"); + } + } HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { if let Err(e) = reload_http_route_workspaced_route_setting(db).await { tracing::error!(error = %e, "Could not reload http route workspaced route setting"); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 9f8605c17a..dc8072d684 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -106,8 +106,9 @@ use windmill_common::{ use windmill_common::{ client::AuthedClient, global_settings::{ - APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE, - HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, + parse_allowed_origins_setting, APP_WORKSPACED_ROUTE_SETTING, + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, + HTTP_ROUTE_WORKSPACED_ROUTE, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, }, queue_metrics::{ QueueSample, QUEUE_COUNT_PREFIX, QUEUE_DELAY_PREFIX, QUEUE_DELAY_SAME_HEAD_SECS, @@ -428,6 +429,18 @@ pub async fn initial_load( pass.setting(APP_WORKSPACED_ROUTE_SETTING, false, |v| async move { apply_app_workspaced_route_setting(v) }); + pass.setting( + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, + false, + |v| async move { + if let Err(e) = apply_http_route_default_allowed_origins_setting(v) { + tracing::error!( + "Error reloading http route default allowed origins: {:?}", + e + ) + } + }, + ); pass.setting( HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, false, @@ -4698,6 +4711,14 @@ pub async fn poll_git_auto_pull(db: &Pool) { { tracing::error!("git auto-pull: advisory unlock failed: {e:#}"); } + + // Backstop for the "Windmill CI tests" checks: retry a failed GitHub create or + // delivery, conclude checks whose tests settled, time out stuck ones, prune old + // rows. Detached and outside the advisory lock: its writes are guarded (claimed + // conclude, greatest-id upsert), it is single-flight, and its GitHub calls must not + // count against the monitor pass's budget. + let db = db.clone(); + tokio::spawn(async move { windmill_git_sync::sweep_ci_test_checks(&db).await }); } #[cfg(feature = "private")] @@ -7002,6 +7023,34 @@ pub fn apply_app_workspaced_route_setting(app_workspaced_route: Option error::Result<()> { + let v = + load_value_from_global_settings(conn, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING).await?; + apply_http_route_default_allowed_origins_setting(v) +} + +pub fn apply_http_route_default_allowed_origins_setting( + value: Option, +) -> error::Result<()> { + // A bad value leaves whatever is already loaded in place rather than + // reverting to no restriction. On the boot path that is still the empty + // default, so what keeps a stored typo from widening CORS instance-wide is + // write-time validation, not this. + let origins = match parse_allowed_origins_setting(value.as_ref()) { + Ok(origins) => origins, + Err(err) => { + tracing::error!( + "Invalid {} setting, keeping the previous value: {err:#}", + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING + ); + return Ok(()); + } + }; + + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.store(std::sync::Arc::new(origins)); + Ok(()) +} + pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> { let v = load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?; apply_http_route_workspaced_route_setting(conn, v).await diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index d0f9d63647..41dd70ca93 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -113,6 +113,11 @@ folder: name(char), workspace_id(char), display_name(char), owners(char), extra_ folder_permission_history: id(bigint), workspace_id(char), folder_name(char), changed_by(char), changed_at(ts), change_type(char), affected(char) FK: (workspace_id, folder_name) -> folder(workspace_id, name) gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[]) +git_sync_ci_test_check: workspace_id(char), head_sha(char), head_ref(char), poster_workspace_id(char), repo_url(text), repo_resource_path(char), check_run_id(bigint), created_at(timestamptz), concluded(bool), conclusion(text), concluded_at(timestamptz), github_posted(bool) + FK: (workspace_id) -> workspace(id) + FK: (poster_workspace_id) -> workspace(id) +git_sync_synced_head: workspace_id(char), repo_resource_path(char), branch(char), sha(char), source(char), job_id(uuid), synced_at(timestamptz), tests_dispatched_at(timestamptz), ci_test_job_ids(uuid[]) + FK: (workspace_id) -> workspace(id) global_settings: name(char), value(jsonb), updated_at(ts) guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz), jwt_entry(bool) group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb) @@ -120,7 +125,7 @@ group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb) group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char) FK: (workspace_id, group_name) -> group_(workspace_id, name) healthchecks: id(bigint), check_type(text), healthy(bool), created_at(ts) -http_trigger: path(char), route_path(char), route_path_key(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), authentication_method(authentication_method), http_method(http_method), static_asset_config(jsonb), is_static_website(bool), workspaced_route(bool), wrap_body(bool), raw_string(bool), authentication_resource_path(char), summary(char), description(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), request_type(request_type), mode(trigger_mode), labels(text[]) +http_trigger: path(char), route_path(char), route_path_key(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), authentication_method(authentication_method), http_method(http_method), static_asset_config(jsonb), is_static_website(bool), workspaced_route(bool), wrap_body(bool), raw_string(bool), allowed_origins(text[]), authentication_resource_path(char), summary(char), description(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), request_type(request_type), mode(trigger_mode), labels(text[]) input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_type), name(text), args(jsonb), created_at(ts), created_by(char), is_public(bool) FK: (workspace_id) -> workspace(id) instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 95b4a62141..590963b291 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -990,6 +990,76 @@ export function main() { return [ns === isNumber, label, local()]; }"# Ok(()) } +async fn bun_dependency_lock(db: &Pool, 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) -> 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) -> anyhow::Result<()> { initialize_tracing().await; diff --git a/backend/tests/fixtures/git_sync_autopull_recovery.sql b/backend/tests/fixtures/git_sync_autopull_recovery.sql new file mode 100644 index 0000000000..6392da50ab --- /dev/null +++ b/backend/tests/fixtures/git_sync_autopull_recovery.sql @@ -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"}}}]}'); diff --git a/backend/tests/folder_default_permissioned_as.rs b/backend/tests/folder_default_permissioned_as.rs index 7aa4f0a003..238d7cdb32 100644 --- a/backend/tests/folder_default_permissioned_as.rs +++ b/backend/tests/folder_default_permissioned_as.rs @@ -630,7 +630,7 @@ async fn test_folder_default_permissioned_as(db: Pool) -> 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 diff --git a/backend/tests/git_sync_autopull_recovery.rs b/backend/tests/git_sync_autopull_recovery.rs new file mode 100644 index 0000000000..d3ba8b7c9f --- /dev/null +++ b/backend/tests/git_sync_autopull_recovery.rs @@ -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) -> anyhow::Result { + 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) -> 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) -> 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) -> 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, +) -> 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(()) +} diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 76102656a5..64d7854173 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1442,7 +1442,10 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool "the other settings in the same apply must not have been written either" ); } + +#[sqlx::test(fixtures("base"))] +async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool) { + // The declarative writers (the sync-config CLI, the operator's ConfigMap + // sync) do not run the HTTP layer's pre-write hook, so an origin list that + // cannot be parsed would persist here, be dropped at boot, and leave the + // instance with no restriction at all. + clear_settings_and_configs(&db).await; + let before = count_global_settings(&db).await; + + for bad in [ + serde_json::json!([""]), + serde_json::json!(["https://a.example,https://b.example"]), + serde_json::json!("null"), + ] { + let mut desired = BTreeMap::new(); + desired.insert( + "http_route_default_allowed_origins".to_string(), + bad.clone(), + ); + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err(&format!("{bad} must fail the sync")); + assert!( + err.to_string() + .contains("http_route_default_allowed_origins"), + "the error should name the offending setting, got: {err}" + ); + } + + assert_eq!( + count_global_settings(&db).await, + before, + "a rejected sync must not have persisted anything" + ); + + // A usable list still syncs. + let mut desired = BTreeMap::new(); + desired.insert( + "http_route_default_allowed_origins".to_string(), + serde_json::json!(["https://app.example.com"]), + ); + windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect("a valid origin list must sync"); +} diff --git a/backend/tests/preserve_on_behalf_of.rs b/backend/tests/preserve_on_behalf_of.rs index 2a1f3895ca..0c8c0b76a3 100644 --- a/backend/tests/preserve_on_behalf_of.rs +++ b/backend/tests/preserve_on_behalf_of.rs @@ -405,11 +405,12 @@ async fn test_preserve_on_behalf_of(db: Pool) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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, +) -> 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) -> 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) -> 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", diff --git a/backend/tests/wm_token_confinement.rs b/backend/tests/wm_token_confinement.rs index 39b86c59a1..23d88301b7 100644 --- a/backend/tests/wm_token_confinement.rs +++ b/backend/tests/wm_token_confinement.rs @@ -318,19 +318,35 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool) -> 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( diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 22f95aefb8..3e2bae5092 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5483,7 +5483,7 @@ async fn test_flow_substep_tag_availability_check(db: Pool) -> 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; diff --git a/backend/tests/ws_specific.rs b/backend/tests/ws_specific.rs index cc15161381..322de598b7 100644 --- a/backend/tests/ws_specific.rs +++ b/backend/tests/ws_specific.rs @@ -391,7 +391,8 @@ async fn test_create_resource_upsert_clears_ws_specific(db: Pool) -> a /// Regression for GHSA-xmr2-98m6-cjf7: a token scoped only to `resources:write:` /// 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, @@ -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 = + 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, +) -> 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) -> 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(()) } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index b9edde3c6d..04513b4c05 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -1053,6 +1053,10 @@ pub async fn fetch_api_authed_from_permissioned_as( db: &DB, username_override: Option, ) -> error::Result { + // 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, diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 47ea12c04b..29ee0fe36a 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -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" diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index ecb7ba405b..2093ac5fec 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -827,6 +827,29 @@ async fn create_flow( WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() }, ); + // Trigger CI tests for items that reference this flow + { + let db2 = db.clone(); + let w_id2 = w_id.clone(); + let flow_path2 = nf.path.clone(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id2, + &flow_path2, + "flow", + &email2, + &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after flow creation"); + } + }); + } + Ok((StatusCode::CREATED, nf.path.to_string())) } @@ -922,7 +945,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?, diff --git a/backend/windmill-api-integration-tests/tests/offboarding.rs b/backend/windmill-api-integration-tests/tests/offboarding.rs index 4434e6a2b5..7bdeab924b 100644 --- a/backend/windmill-api-integration-tests/tests/offboarding.rs +++ b/backend/windmill-api-integration-tests/tests/offboarding.rs @@ -599,3 +599,59 @@ async fn test_offboard_invalid_target(db: Pool) -> 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, +) -> 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 = 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(()) +} diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 1b6b49a136..d581608c10 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -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!( diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e7be0d5ede..0fe358da1c 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -58,12 +58,13 @@ use windmill_common::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, - 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, - WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, - WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING, + GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_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, 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, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -1047,6 +1048,12 @@ async fn run_setting_pre_write_hook( } } } + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING => { + // Rejected at write time rather than at boot: a mistyped origin + // matches no request, so it would silently block the very app it + // names with nothing but a log line to go on. + windmill_common::global_settings::parse_allowed_origins_setting(Some(value))?; + } HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => { let serde_json::Value::Bool(workspaced_route) = value else { return Err(error::Error::BadRequest(format!( @@ -1321,11 +1328,19 @@ 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 && key != APP_WORKSPACED_ROUTE_SETTING && key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING + // The route editor shows the inherited default to whoever is editing a + // trigger, who is usually not a superadmin. Not a secret either: any + // browser discovers the list by reading Access-Control-Allow-Origin off + // a response. + && key != HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING && key != WS_BASE_URL_SETTING && key != INSTANCE_BANNER_SETTING { diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index afe7580055..260df2f969 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -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) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 879e739dc0..3a2b1d715a 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6056,7 +6056,7 @@ async fn clone_triggers_and_schedules( path, route_path, route_path_key, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, authentication_method, http_method, static_asset_config, is_static_website, workspaced_route, wrap_body, - raw_string, authentication_resource_path, summary, description, + raw_string, allowed_origins, authentication_resource_path, summary, description, error_handler_path, error_handler_args, retry, request_type, mode, permissioned_as, labels ) @@ -6064,7 +6064,7 @@ async fn clone_triggers_and_schedules( path, route_path, route_path_key, script_path, is_flow, $1, edited_by, edited_at, extra_perms, authentication_method, http_method, static_asset_config, is_static_website, workspaced_route, wrap_body, - raw_string, authentication_resource_path, summary, description, + raw_string, allowed_origins, authentication_resource_path, summary, description, error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE, permissioned_as, labels FROM http_trigger diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0094f60a18..5c3d7bf737 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.809.0 + version: 1.811.1 title: Windmill API contact: @@ -30988,6 +30988,14 @@ components: raw_string: type: boolean description: If true, passes the request body as a raw string instead of parsing as JSON + allowed_origins: + type: array + nullable: true + maxItems: 100 + items: + type: string + maxLength: 256 + description: "Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. When set, the list governs both the preflight and the response, overriding any Access-Control-Allow-Origin the runnable returns via wm_headers. Use ['*'] to opt out of any restriction, including the http_route_default_allowed_origins instance setting. An empty list is not a configuration and resolves exactly as null does. When null, the instance setting applies, or Access-Control-Allow-Origin: * if it is unset. Ignored on a static website, which has no authentication of its own and so hands out public files: restricting which browsers may read them protects nothing while breaking cross-origin webfonts and fetches. A single-file static asset is not exempt, since it can carry an authentication_method." error_handler_path: type: string description: Path to a script to run when the triggered job fails. A bare @@ -31079,6 +31087,14 @@ components: raw_string: type: boolean description: If true, passes the request body as a raw string instead of parsing as JSON + allowed_origins: + type: array + nullable: true + maxItems: 100 + items: + type: string + maxLength: 256 + description: "Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. When set, the list governs both the preflight and the response, overriding any Access-Control-Allow-Origin the runnable returns via wm_headers. Use ['*'] to opt out of any restriction, including the http_route_default_allowed_origins instance setting. An empty list is not a configuration and resolves exactly as null does. When null, the instance setting applies, or Access-Control-Allow-Origin: * if it is unset. Ignored on a static website, which has no authentication of its own and so hands out public files: restricting which browsers may read them protects nothing while breaking cross-origin webfonts and fetches. A single-file static asset is not exempt, since it can carry an authentication_method." error_handler_path: type: string description: Path to a script to run when the triggered job fails. A bare @@ -31177,6 +31193,14 @@ components: raw_string: type: boolean description: If true, passes the request body as a raw string instead of parsing as JSON + allowed_origins: + type: array + nullable: true + maxItems: 100 + items: + type: string + maxLength: 256 + description: "Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. When set, the list governs both the preflight and the response, overriding any Access-Control-Allow-Origin the runnable returns via wm_headers. Use ['*'] to opt out of any restriction, including the http_route_default_allowed_origins instance setting. An empty list is not a configuration and resolves exactly as null does. When null, the instance setting applies, or Access-Control-Allow-Origin: * if it is unset. Ignored on a static website, which has no authentication of its own and so hands out public files: restricting which browsers may read them protects nothing while breaking cross-origin webfonts and fetches. A single-file static asset is not exempt, since it can carry an authentication_method." error_handler_path: type: string description: Path to a script to run when the triggered job fails. A bare @@ -33935,8 +33959,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: > diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 67b3918804..aa16e55757 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -491,6 +491,13 @@ pub struct S3Key { #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Policy { pub on_behalf_of: Option, + /// 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, //paths: // - script/ @@ -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 = 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> = 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 = 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 = 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, + 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); @@ -4614,7 +4621,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(), @@ -5025,7 +5032,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)) @@ -5536,7 +5543,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> { + 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 { + 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() @@ -5547,16 +5592,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 @@ -5703,7 +5747,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", diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index 2998faa7f3..26eb06c8e8 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -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 { + 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 { 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 { 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 ) diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index e58aab44a2..387ba56589 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -1,6 +1,7 @@ use super::{ - http_trigger_args::RawHttpTriggerArgs, refresh_routers, AuthenticationMethod, HttpMethod, - RequestType, TriggerRoute, HTTP_ACCESS_CACHE, HTTP_AUTH_CACHE, HTTP_ROUTERS_CACHE, + effective_allowed_origins, http_trigger_args::RawHttpTriggerArgs, match_origin, + refresh_routers, AuthenticationMethod, HttpMethod, RequestType, TriggerRoute, + HTTP_ACCESS_CACHE, HTTP_AUTH_CACHE, HTTP_ROUTERS_CACHE, }; use crate::{ auth::{AuthCache, OptTokened}, @@ -24,6 +25,7 @@ use std::{collections::HashMap, sync::Arc}; use windmill_common::{ db::UserDB, error::{Error, Result}, + global_settings::HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS, jobs::JobTriggerKind, triggers::{TriggerKind, TriggerMetadata}, utils::{not_found_if_none, StripPath}, @@ -37,12 +39,222 @@ use { windmill_object_store::build_object_store_client, }; +/// Which router a request's CORS decision must be looked up in. +/// +/// A preflight names the method it is asking about in +/// `Access-Control-Request-Method`; the routers are keyed by method, so without +/// that header there is nothing to look up. +fn cors_lookup_method(req: &axum::extract::Request) -> Option { + let method = req.method(); + if method == http::Method::OPTIONS { + req.headers() + .get(http::header::ACCESS_CONTROL_REQUEST_METHOD) + .and_then(|method| method.to_str().ok()) + .and_then(|method| http::Method::try_from(method).ok()) + .as_ref() + .and_then(routable_method) + } else { + routable_method(method) + } +} + +/// The router key a request method maps to. `HEAD` resolves the `GET` route it +/// mirrors, and does so for a preflight naming it too: browsers send +/// `Access-Control-Request-Method: HEAD` when the HEAD carries a non-safelisted +/// header, and answering that preflight from a different route than the request +/// itself resolves is how the two come to disagree. +fn routable_method(method: &http::Method) -> Option { + if method == http::Method::HEAD { + Some(HttpMethod::Get) + } else { + HttpMethod::try_from(method).ok() + } +} + +/// The key to look a request up by, matching what `route_job` resolves it to. +/// +/// `Path` percent-decodes before `get_http_route_trigger` builds its +/// lookup key, so decoding here is what keeps the two agreeing: on the raw path, +/// `/us%65rs` misses the trigger registered at `/users` that goes on to serve the +/// request, and the response would carry the permissive default instead of that +/// trigger's allowlist. +fn cors_lookup_path(raw_path: &str) -> Option { + let decoded = urlencoding::decode(raw_path).ok()?; + // `StripPath::to_path` strips one leading slash and the handler trims + // trailing ones, before a single `/` is prefixed back on. + let stripped = decoded.strip_prefix('/').unwrap_or(&decoded); + Some(format!("/{}", stripped.trim_end_matches('/'))) +} + +/// What the middleware should stamp, decided while the routers guard is held. +/// +/// Deliberately small and owned: the allowlist itself never leaves the guard, +/// so a large one is scanned in place instead of being copied per request onto +/// a path an unauthenticated preflight can reach. +#[derive(Clone)] +enum CorsDecision { + /// No allowlist applies, so the permissive default stands. + Unrestricted, + /// An allowlist applies. `allow_origin` is the value to echo, present only + /// when the request's own `Origin` is on the list. + Restricted { route_method: Option, allow_origin: Option }, + /// The routers could not be read, so nothing is known about this path. + Unavailable, +} + +/// Whether a route actually serves a static website, rather than merely saying +/// it does. +/// +/// `is_static_website` is a caller-set flag that validation ties to nothing: a +/// route can carry it while having no assets configured and a `script_path` +/// that `route_job` runs regardless. Keying the exemption off the flag alone +/// would let one boolean disable a route's allowlist and hand its runnable back +/// the `wm_headers` escape hatch, so the assets have to be there too. +fn serves_a_static_website(trigger: &TriggerRoute) -> bool { + trigger.is_static_website && trigger.static_asset_config.is_some() +} + +/// A static website is never subject to an allowlist, its own included. It has +/// no authentication of its own — the editor does not offer any — so it hands +/// out public files that any non-browser client can already fetch, and +/// restricting which browsers may read them protects nothing while breaking the +/// cross-origin uses that do consult CORS: a webfont, a `crossorigin` asset, a +/// `fetch`. +/// +/// A single-file static asset is not exempt. That one can carry an +/// `authentication_method`, so its content need not be public, and an allowlist +/// is what keeps another origin from reading a response its own credentials +/// would not have obtained. +/// +/// The CORS verdict for a request, published by whoever resolved its trigger. +/// +/// The middleware stamps headers after the handler returns, but only the +/// handler knows which trigger it actually served. Re-deriving that from the +/// routers cache is a second lookup which can disagree with the first when a +/// route is edited, deleted or widened mid-request, and every ordering of the +/// two is wrong in some case. So the verdict travels with the request instead +/// of being worked out twice. +#[derive(Clone, Default)] +struct ResolvedCorsPolicy(std::sync::Arc>); + +impl ResolvedCorsPolicy { + /// Record what the trigger being served allows. Called once, where the + /// route is resolved, so the answer cannot drift from the response. + fn publish(&self, trigger: &TriggerRoute, method: Option, headers: &HeaderMap) { + let decision = if serves_a_static_website(trigger) { + CorsDecision::Unrestricted + } else { + let instance_default = HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.load(); + match effective_allowed_origins( + trigger.allowed_origins.as_deref(), + instance_default.as_slice(), + ) { + None => CorsDecision::Unrestricted, + Some(allowed_origins) => CorsDecision::Restricted { + route_method: method, + allow_origin: match_origin(allowed_origins, headers.get(http::header::ORIGIN)), + }, + } + }; + let _ = self.0.set(decision); + } + + fn published(&self) -> Option { + self.0.get().cloned() + } +} + +/// Decide the CORS answer from the routers cache, for a request no handler +/// published a verdict for: a preflight, an unknown path, or any failure ahead +/// of the publish — authentication included, which runs after the route itself +/// resolves. +/// +/// Loads the routers when the cache is cold, the way `get_http_route_trigger` +/// does, so a preflight is answered from the same view of the routes as the +/// request that follows it. +async fn resolve_cors_decision( + db: &DB, + http_method: HttpMethod, + requested_path: &str, + origin: Option<&http::HeaderValue>, +) -> CorsDecision { + let routers_cache = HTTP_ROUTERS_CACHE.read().await; + + let routers_cache = if routers_cache.routers.is_empty() { + drop(routers_cache); + match refresh_routers(db, false).await { + Ok((_, routers_cache)) => routers_cache, + Err(err) => { + tracing::error!("Could not load HTTP routers to resolve CORS: {err:#}"); + return CorsDecision::Unavailable; + } + } + } else { + routers_cache + }; + + let Some(router) = routers_cache.routers.get(&http_method) else { + return CorsDecision::Unavailable; + }; + + let route = router.at(requested_path).ok(); + if route + .as_ref() + .is_some_and(|trigger| serves_a_static_website(trigger.value)) + { + return CorsDecision::Unrestricted; + } + let route_allowed_origins = route + .as_ref() + .and_then(|trigger| trigger.value.allowed_origins.as_deref()); + + let instance_default = HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.load(); + match effective_allowed_origins(route_allowed_origins, instance_default.as_slice()) { + None => CorsDecision::Unrestricted, + Some(allowed_origins) => CorsDecision::Restricted { + route_method: route.map(|_| http_method), + allow_origin: match_origin(allowed_origins, origin), + }, + } +} + async fn conditional_cors_middleware( - req: axum::extract::Request, + Extension(db): Extension, + mut req: axum::extract::Request, next: axum::middleware::Next, ) -> Response { + let origin = req.headers().get(http::header::ORIGIN).cloned(); + // Owned before `next.run` consumes the request. `&Request` is not `Send` + // (`Body` is not `Sync`), so nothing borrowed from it can cross the await. + // The URI is carried rather than the decoded path: cloning it is a refcount + // bump, while decoding allocates, and only the fallback below ever needs it. + let lookup_method = cors_lookup_method(&req); + let uri = req.uri().clone(); + + let resolved = ResolvedCorsPolicy::default(); + req.extensions_mut().insert(resolved.clone()); + let mut response = next.run(req).await; + let decision = match resolved.published() { + // The handler resolved a trigger and said what it served under. That is + // the policy this response was produced with, so nothing else can be + // more authoritative. + Some(decision) => decision, + // No verdict was published: a preflight, an unknown path, or a request + // that failed before reaching the publish, authentication included. No + // runnable produced this body, so reading the cache cannot contradict + // anything. + None => match lookup_method.zip(cors_lookup_path(uri.path())) { + Some((method, path)) => { + resolve_cors_decision(&db, method, &path, origin.as_ref()).await + } + // Not a preflight, not a routable method, or a path that does not + // decode. + None => CorsDecision::Unrestricted, + }, + }; + let headers = response.headers_mut(); // Check existing headers first to determine what not to insert @@ -67,18 +279,65 @@ async fn conditional_cors_middleware( } } - // Insert only the missing headers - if !not_insert_origin { - headers.insert( - http::header::ACCESS_CONTROL_ALLOW_ORIGIN, - http::HeaderValue::from_static("*"), - ); + match &decision { + CorsDecision::Restricted { allow_origin, .. } => { + // A configured allowlist decides, overriding any `wm_headers` value + // the runnable set. The preflight is answered before any code runs, + // so config is the only thing it can consult; letting the response + // widen what the preflight advertised would make the two disagree + // and leave the allowlist bounding nothing. A route escapes a + // stricter instance default — `wm_headers` included — by setting + // its own list to `*`. + match allow_origin { + Some(value) => { + headers.insert(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, value.clone()) + } + // No match: omit the header entirely so the browser blocks the + // read, and drop any value the runnable set. + None => headers.remove(http::header::ACCESS_CONTROL_ALLOW_ORIGIN), + }; + // Appended, not inserted: the answer now depends on the request's + // Origin, and a shared cache that ignores it would hand one + // origin's response to another. + headers.append(http::header::VARY, http::HeaderValue::from_static("origin")); + } + // The routers could not be read, so nothing is known about this path; + // only a preflight or an unresolved request reaches here. Answering a + // preflight permissively would let a disallowed origin go on to invoke + // a runnable whose purpose may be a side effect. + CorsDecision::Unavailable => { + headers.remove(http::header::ACCESS_CONTROL_ALLOW_ORIGIN); + } + CorsDecision::Unrestricted => { + if !not_insert_origin { + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + http::HeaderValue::from_static("*"), + ); + } + } } if !not_insert_methods { + // A route accepts exactly one method, so advertising all seven + // overstates it. Only a route under an allowlist gets the narrower + // answer; an unrestricted one advertises the full supported set, since + // narrowing it would say something about a route the response is not + // otherwise willing to disclose. + let restricted_method = match &decision { + CorsDecision::Restricted { route_method, .. } => *route_method, + _ => None, + }; headers.insert( http::header::ACCESS_CONTROL_ALLOW_METHODS, - http::HeaderValue::from_static("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"), + http::HeaderValue::from_static(match restricted_method { + Some(HttpMethod::Get) => "GET, OPTIONS", + Some(HttpMethod::Post) => "POST, OPTIONS", + Some(HttpMethod::Put) => "PUT, OPTIONS", + Some(HttpMethod::Delete) => "DELETE, OPTIONS", + Some(HttpMethod::Patch) => "PATCH, OPTIONS", + None => "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS", + }), ); } @@ -237,6 +496,7 @@ async fn route_job( Extension(db): Extension, Extension(user_db): Extension, Extension(auth_cache): Extension>, + Extension(cors_policy): Extension, OptTokened { token }: OptTokened, Path(route_path): Path, headers: HeaderMap, @@ -255,6 +515,10 @@ async fn route_job( .await .map_err(|e| e.into_response())?; + // Publish before anything else can fail: the CORS middleware stamps this + // response either way, and it must reflect the trigger actually served. + cors_policy.publish(&trigger, routable_method(&args.0.metadata.method), &headers); + if trigger.script_path.is_empty() && trigger.static_asset_config.is_none() { return Err(Error::NotFound(format!( "Runnable path of HTTP route at path: {}", diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index ed66eb5e74..4dd073b3af 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -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, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e0128e9e7a..828509519e 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -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?; diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index b51186f464..73cdfba35d 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -452,6 +452,42 @@ async fn fetch_authed_from_permissioned_as_inner( w_id: &str, conn: &mut sqlx::PgConnection, ) -> Result { + // 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?; diff --git a/backend/windmill-common/src/folders.rs b/backend/windmill-common/src/folders.rs index 2a6ba935b6..fa4dfdf3b7 100644 --- a/backend/windmill-common/src/folders.rs +++ b/backend/windmill-common/src/folders.rs @@ -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))) diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 1d590e38bf..c0ed63cd53 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -118,6 +118,7 @@ pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const OTEL_TRACES_RETENTION_SECS_SETTING: &str = "otel_traces_retention_secs"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; pub const HTTP_ROUTE_WORKSPACED_ROUTE_SETTING: &str = "http_route_workspaced_route"; +pub const HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING: &str = "http_route_default_allowed_origins"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; @@ -362,6 +363,125 @@ use std::sync::atomic::AtomicBool; lazy_static::lazy_static! { pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false); pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false); + /// Origins HTTP routes allow cross-origin when they configure none of their + /// own. Empty means unset, which keeps the historical `*`. + pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap> = + arc_swap::ArcSwap::from_pointee(vec![]); +} + +/// Whether an allowlist places no restriction at all. +/// +/// `*` is the explicit "open on purpose" entry, and a route carrying it behaves +/// exactly as an unconfigured one: it is how a route opts out of a stricter +/// instance default, including back into the `wm_headers` escape hatch. +pub fn allows_any_origin(allowed_origins: &[String]) -> bool { + allowed_origins.iter().any(|allowed| allowed == "*") +} + +/// An allowlist is scanned on every request to a restricted route, including +/// the unauthenticated preflight, so its size is a request cost anyone can +/// trigger. +pub const MAX_ALLOWED_ORIGINS: usize = 100; +pub const MAX_ALLOWED_ORIGIN_LEN: usize = 256; + +/// Reject allowlist entries that cannot be compared, stored, or safely allowed. +/// +/// The stored string is only ever an operand: `match_origin` echoes the +/// request's own `Origin` back, never this value, so a malformed entry matches +/// nothing and fails closed. Shapes that merely cannot match are the editor's +/// business to warn about, not this function's to refuse. What is left are the +/// three cases where permissiveness costs something: `null` is what every +/// sandboxed iframe sends, so allowing it would admit any page that can open +/// one; a comma cannot survive the editor's comma-separated field, which would +/// silently split one entry into two and widen the list; and an unbounded list +/// makes every preflight pay for it. +pub fn validate_allowed_origins(allowed_origins: &[String]) -> crate::error::Result<()> { + if allowed_origins.len() > MAX_ALLOWED_ORIGINS { + return Err(crate::error::Error::BadRequest(format!( + "At most {} allowed origins, got {}.", + MAX_ALLOWED_ORIGINS, + allowed_origins.len() + ))); + } + + for origin in allowed_origins { + if origin == "*" { + continue; + } + + let invalid = |reason: &str| { + crate::error::Error::BadRequest(format!( + "Invalid allowed origin '{}': {}.", + origin, reason + )) + }; + + if origin.is_empty() { + return Err(invalid("must not be empty")); + } + if origin.len() > MAX_ALLOWED_ORIGIN_LEN { + return Err(invalid("is longer than any origin a browser sends")); + } + // The editor edits the whole list as one comma-separated field, so an + // entry carrying a comma comes back as two and widens the list. + if origin.contains(',') { + return Err(invalid("must not contain a comma, which separates entries")); + } + if origin.eq_ignore_ascii_case("null") { + return Err(invalid( + "'null' is what a sandboxed iframe sends, so allowing it would allow any page that can open one", + )); + } + // An Origin header is always visible ASCII, so a value outside it can + // never be the string this is compared against. + if !origin.chars().all(|c| c.is_ascii_graphic()) { + return Err(invalid( + "must contain only visible ASCII, with no whitespace", + )); + } + } + + Ok(()) +} + +/// Read [`HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING`] from its stored value. +/// +/// Accepts the comma-separated string the settings UI writes, or a JSON array +/// for anything setting it through the API directly. +pub fn parse_allowed_origins_setting( + value: Option<&serde_json::Value>, +) -> crate::error::Result> { + let origins = match value { + None | Some(serde_json::Value::Null) => vec![], + Some(serde_json::Value::String(raw)) => raw + .split(',') + .map(|origin| origin.trim().to_string()) + .filter(|origin| !origin.is_empty()) + .collect(), + Some(serde_json::Value::Array(entries)) => entries + .iter() + .map(|entry| match entry { + serde_json::Value::String(origin) => Ok(origin.trim().to_string()), + _ => Err(crate::error::Error::BadRequest(format!( + "{} entries must be strings", + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING + ))), + }) + // Not filtered for empties, unlike the string form: there a + // trailing separator naturally yields an empty token, whereas an + // empty array entry is something the caller wrote and validation + // should reject rather than silently drop. + .collect::>>()?, + Some(_) => { + return Err(crate::error::Error::BadRequest(format!( + "{} expected to be a comma-separated string or an array of strings", + HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING + ))) + } + }; + + validate_allowed_origins(&origins)?; + Ok(origins) } pub const ENV_SETTINGS: &[&str] = &[ diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index e28bf139cf..1dd3396809 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1288,8 +1288,9 @@ pub fn diff_worker_configs( ConfigsDiff { upserts, deletes } } -/// Declaratively replace the global settings, rejecting a `github_app_webhook_base_url` -/// the API would reject. +/// Declaratively replace the global settings, rejecting a +/// `github_app_webhook_base_url` or `http_route_default_allowed_origins` the +/// API would reject. /// /// Every declarative writer (the `sync-config` CLI, the Kubernetes operator's /// ConfigMap sync) MUST go through this rather than calling @@ -1348,6 +1349,13 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, } + // An origin list that cannot be parsed is dropped at boot, leaving the + // empty default — which is no restriction at all. Rejecting it here is what + // keeps a typo in a ConfigMap from silently widening CORS instance-wide. + let origins_key = crate::global_settings::HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING; + crate::global_settings::parse_allowed_origins_setting(desired.get(origins_key)) + .map_err(|e| anyhow::anyhow!("{origins_key}: {e}"))?; + let diff = diff_global_settings(current, desired, ApplyMode::Replace); apply_settings_diff(db, &diff).await?; diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 1639b4605d..dce40048f5 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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(), diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 00aae89b43..628ae2a709 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -91,9 +91,6 @@ async fn prefetch_cached_script_inner( derive_email: bool, ) -> crate::error::Result> { 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, diff --git a/backend/windmill-common/src/users.rs b/backend/windmill-common/src/users.rs index 3d00c5d312..1a49605e59 100644 --- a/backend/windmill-common/src/users.rs +++ b/backend/windmill-common/src/users.rs @@ -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, diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 45c8695585..ad040c5905 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -191,7 +191,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28958/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28948/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28957/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. @@ -559,9 +559,10 @@ impl AutoPullSettings { /// Whether a freshly observed `(git_ref, head_sha)` warrants enqueuing a pull. /// /// A trigger (poll or webhook) is only a hint: we pull when auto-pull is - /// enabled and the observed head differs from the last sha we synced for - /// that ref. Re-observing the same head (e.g. a redundant poll, or the - /// commit our own deploy callback just pushed back) is a no-op. + /// enabled and the observed head differs from the last sha we pulled for + /// that ref. Re-observing the same head (a redundant poll) is a no-op. A + /// commit our own deploy pushed is not: pushes never write here, so the pull + /// it triggers picks up anything pushed under it. pub fn should_pull(&self, git_ref: &str, head_sha: &str) -> bool { self.enabled && self.last_synced_sha.get(git_ref).map(String::as_str) != Some(head_sha) } diff --git a/backend/windmill-common/tests/notify_events.rs b/backend/windmill-common/tests/notify_events.rs index 8161130875..285086308e 100644 --- a/backend/windmill-common/tests/notify_events.rs +++ b/backend/windmill-common/tests/notify_events.rs @@ -360,6 +360,77 @@ async fn test_trigger_notify_workspace_key_change(db: Pool) { ); } +/// 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) { + 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) { // First insert a session token with token_hash and token_prefix diff --git a/backend/windmill-common/tests/permissioned_as_authz.rs b/backend/windmill-common/tests/permissioned_as_authz.rs new file mode 100644 index 0000000000..25c219845f --- /dev/null +++ b/backend/windmill-common/tests/permissioned_as_authz.rs @@ -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) { + // `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) { + 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)) + ); + } +} diff --git a/backend/windmill-dep-map/src/ci_tests.rs b/backend/windmill-dep-map/src/ci_tests.rs index 79f1b7e30b..8f56111b35 100644 --- a/backend/windmill-dep-map/src/ci_tests.rs +++ b/backend/windmill-dep-map/src/ci_tests.rs @@ -19,3 +19,13 @@ pub async fn trigger_ci_tests_for_item( ) -> error::Result> { Ok(vec![]) } + +#[cfg(not(feature = "private"))] +pub async fn trigger_all_ci_tests( + _db: &sqlx::Pool, + _w_id: &str, + _email: &str, + _username: &str, +) -> error::Result> { + Ok(vec![]) +} diff --git a/backend/windmill-git-sync/Cargo.toml b/backend/windmill-git-sync/Cargo.toml index 148746dcca..f74581ba67 100644 --- a/backend/windmill-git-sync/Cargo.toml +++ b/backend/windmill-git-sync/Cargo.toml @@ -9,7 +9,7 @@ name = "windmill_git_sync" path = "./src/lib.rs" [features] -private = ["windmill-common/private"] +private = ["windmill-common/private", "windmill-dep-map/private"] enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"] all_sqlx_features = ["enterprise"] default = [] @@ -22,5 +22,6 @@ serde_json.workspace = true tracing.workspace = true windmill-common = { workspace = true, default-features = false } windmill-queue.workspace = true +windmill-dep-map.workspace = true regex = "1.10.3" tokio = { workspace = true, features = ["full"] } \ No newline at end of file diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index 2bfc7d2f77..d65e230988 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -14,12 +14,21 @@ 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, + 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, record_synced_head, sweep_ci_test_checks, tally_deployed_object_changes, }; +// The CI-test check exists only on enterprise builds; `private` alone (the CE image) +// compiles git_sync_ee without them. +#[cfg(all(feature = "private", feature = "enterprise"))] +pub use git_sync_ee::{ + ensure_ci_test_check_for_pr, evaluate_and_conclude_ci_test_checks, + post_ci_test_check_not_applicable, resolve_pr_head_workspace, +}; + #[cfg(not(feature = "private"))] pub use git_sync_oss::{ handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 32fde93b81..a8a8e83883 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -88,8 +88,10 @@ pub struct OAuthConfig { #[serde(default = "empty_string")] pub token_url: String, pub userinfo_url: Option, - /// 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>, /// Default scopes for the client-credentials (2-legged) flow. These differ /// from the authorization-code `scopes` for most providers (member/consent diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 5bb9a1120f..b6380c02d0 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -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, 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 { diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 2df5dbab5f..126cf97106 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -7,7 +7,7 @@ */ use dashmap::DashMap; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::IpAddr; use std::sync::LazyLock; @@ -1297,9 +1297,26 @@ async fn create_resource( webhook.send_message( w_id.clone(), - WebhookMessage::CreateResource { workspace: w_id, path: resource.path.clone() }, + WebhookMessage::CreateResource { workspace: w_id.clone(), path: resource.path.clone() }, ); + // Trigger CI tests for items that reference this resource + { + let db2 = db.clone(); + let path2 = resource.path.clone(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, &w_id, &path2, "resource", &email2, &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after resource creation"); + } + }); + } + Ok(( StatusCode::CREATED, format!("resource {} created", resource.path), @@ -1339,53 +1356,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 = 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> = - 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 = 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 = if linked_var_paths.is_empty() { - Vec::new() - } else { - let placeholders: Vec = 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 +1380,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 = 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 = 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 = 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 +1448,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 +1517,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) { +/// 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) { 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:`), 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) { + 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 `_`, 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 `_` 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> { + 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)>, + /// Candidate paths a resource outside the requested set still references. + referenced_outside: HashSet, +} + +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 { + 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 = 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 { + let rows: Vec<(String, Option)> = 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)> = Vec::new(); + for (path, value) in rows { + let mut owned: Vec = Vec::new(); + let mut refs: Vec = 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 = 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:`), 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 +1818,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 = Vec::new(); - for path in &request.paths { - let trash_resource: Option = 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 = 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 = if this_linked.is_empty() { - Vec::new() - } else { - let placeholders: Vec = 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 = 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 = Vec::new(); + if let Some(value) = res_data.get("value") { + collect_var_refs(value, &mut refs); + } + let this_linked: Vec = 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 +1917,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?; } diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 3766a6050d..773616a807 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -158,6 +158,7 @@ pub struct RunJob { pub payload: JobPayload, pub args: serde_json::Map, pub scheduled_for_o: Option>, + pub username: String, pub email: String, pub job_id: Option, pub workspace_id: String, @@ -169,6 +170,7 @@ impl From 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) -> 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, email: impl Into) -> Self { + self.username = username.into(); self.email = email.into(); self } @@ -206,7 +212,7 @@ impl RunJob { } pub async fn push(self, db: &Pool) -> 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, diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 68986bb6f9..01ddfd4a81 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -9,7 +9,7 @@ use sqlx::PgConnection; use std::collections::HashSet; use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_audit::{audit_oss::audit_log, ActionKind}; -use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; +use windmill_common::global_settings::{validate_allowed_origins, HTTP_ROUTE_WORKSPACED_ROUTE}; use windmill_common::{ db::UserDB, error::{Error, Result}, @@ -189,6 +189,7 @@ pub async fn insert_new_trigger_into_db( authentication_resource_path, wrap_body, raw_string, + allowed_origins, script_path, summary, description, @@ -207,7 +208,7 @@ pub async fn insert_new_trigger_into_db( retry ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, now(), $20, $21, $22, $23 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, now(), $21, $22, $23, $24 ) "#, w_id, @@ -218,6 +219,7 @@ pub async fn insert_new_trigger_into_db( trigger.config.authentication_resource_path, trigger.config.wrap_body.unwrap_or(false), trigger.config.raw_string.unwrap_or(false), + trigger.config.allowed_origins.as_deref(), trigger.base.script_path, trigger.config.summary, trigger.config.description, @@ -444,6 +446,7 @@ impl TriggerCrud for HttpTrigger { "workspaced_route", "wrap_body", "raw_string", + "allowed_origins", ]; fn get_deployed_object(path: String, parent_path: Option) -> DeployedObject { @@ -474,6 +477,8 @@ impl TriggerCrud for HttpTrigger { validate_authentication_method(new.authentication_method, new.raw_string)?; + validate_allowed_origins(new.allowed_origins.as_deref().unwrap_or_default())?; + Ok(()) } @@ -492,6 +497,8 @@ impl TriggerCrud for HttpTrigger { validate_authentication_method(edit.authentication_method, edit.raw_string)?; + validate_allowed_origins(edit.allowed_origins.as_deref().unwrap_or_default())?; + Ok(()) } @@ -554,33 +561,35 @@ impl TriggerCrud for HttpTrigger { workspaced_route = $3, wrap_body = $4, raw_string = $5, - authentication_resource_path = $6, - script_path = $7, - path = $8, - is_flow = $9, - mode = $10, - http_method = $11, - static_asset_config = $12, - edited_by = $13, - permissioned_as = $14, - request_type = $15, - authentication_method = $16, - summary = $17, - description = $18, + allowed_origins = $6, + authentication_resource_path = $7, + script_path = $8, + path = $9, + is_flow = $10, + mode = $11, + http_method = $12, + static_asset_config = $13, + edited_by = $14, + permissioned_as = $15, + request_type = $16, + authentication_method = $17, + summary = $18, + description = $19, edited_at = now(), - is_static_website = $19, - error_handler_path = $20, - error_handler_args = $21, - retry = $22 + is_static_website = $20, + error_handler_path = $21, + error_handler_args = $22, + retry = $23 WHERE - workspace_id = $23 AND - path = $24 + workspace_id = $24 AND + path = $25 "#, route_path, &route_path_key, Some(effective_workspaced), trigger.config.wrap_body.unwrap_or(false), trigger.config.raw_string.unwrap_or(false), + trigger.config.allowed_origins.as_deref(), trigger.config.authentication_resource_path, trigger.base.script_path, trigger.base.path, @@ -613,30 +622,32 @@ impl TriggerCrud for HttpTrigger { SET wrap_body = $1, raw_string = $2, - authentication_resource_path = $3, - script_path = $4, - path = $5, - is_flow = $6, - mode = $7, - http_method = $8, - static_asset_config = $9, - edited_by = $10, - permissioned_as = $11, - request_type = $12, - authentication_method = $13, - summary = $14, - description = $15, + allowed_origins = $3, + authentication_resource_path = $4, + script_path = $5, + path = $6, + is_flow = $7, + mode = $8, + http_method = $9, + static_asset_config = $10, + edited_by = $11, + permissioned_as = $12, + request_type = $13, + authentication_method = $14, + summary = $15, + description = $16, edited_at = now(), - is_static_website = $16, - error_handler_path = $17, - error_handler_args = $18, - retry = $19 + is_static_website = $17, + error_handler_path = $18, + error_handler_args = $19, + retry = $20 WHERE - workspace_id = $20 AND - path = $21 + workspace_id = $21 AND + path = $22 "#, trigger.config.wrap_body.unwrap_or(false), trigger.config.raw_string.unwrap_or(false), + trigger.config.allowed_origins.as_deref(), trigger.config.authentication_resource_path, trigger.base.script_path, trigger.base.path, diff --git a/backend/windmill-trigger-http/src/lib.rs b/backend/windmill-trigger-http/src/lib.rs index b78276c28d..40bab102a5 100644 --- a/backend/windmill-trigger-http/src/lib.rs +++ b/backend/windmill-trigger-http/src/lib.rs @@ -8,7 +8,7 @@ use tokio::sync::{RwLock, RwLockReadGuard}; use windmill_common::{ error::{Error, Result}, flows::Retry, - global_settings::HTTP_ROUTE_WORKSPACED_ROUTE, + global_settings::{allows_any_origin, HTTP_ROUTE_WORKSPACED_ROUTE}, utils::ExpiringCacheEntry, worker::CLOUD_HOSTED, DB, @@ -51,6 +51,7 @@ pub struct TriggerRoute { pub workspaced_route: bool, pub wrap_body: bool, pub raw_string: bool, + pub allowed_origins: Option>, pub error_handler_path: Option, pub error_handler_args: Option>>, pub retry: Option>, @@ -127,6 +128,7 @@ pub struct HttpConfig { pub workspaced_route: bool, pub wrap_body: bool, pub raw_string: bool, + pub allowed_origins: Option>, } #[derive(Debug, Clone, Serialize)] @@ -144,6 +146,7 @@ pub struct HttpConfigRequest { pub workspaced_route: Option, pub wrap_body: Option, pub raw_string: Option, + pub allowed_origins: Option>, } #[derive(Deserialize)] @@ -162,6 +165,7 @@ struct HttpConfigRequestHelper { workspaced_route: Option, wrap_body: Option, raw_string: Option, + allowed_origins: Option>, } impl<'de> Deserialize<'de> for HttpConfigRequest { @@ -197,6 +201,7 @@ impl<'de> Deserialize<'de> for HttpConfigRequest { workspaced_route: helper.workspaced_route, wrap_body: helper.wrap_body, raw_string: helper.raw_string, + allowed_origins: helper.allowed_origins, }) } } @@ -216,6 +221,50 @@ pub struct RouteExists { pub workspaced_route: Option, } +/// The allowlist that governs a route: its own when it has one, otherwise the +/// instance-wide default. `None` means nothing is configured at either level, so +/// the route keeps the historical permissive behaviour. +/// +/// A list containing `*` is treated as no restriction, which is how a route opts +/// out of a stricter instance default. +pub fn effective_allowed_origins<'a>( + route_allowed_origins: Option<&'a [String]>, + instance_default: &'a [String], +) -> Option<&'a [String]> { + // An empty list is not a configuration. It reads exactly as never having set + // one, so such a route still inherits the instance default rather than + // skipping it, which is what would make `[]` more permissive than `NULL`. + match route_allowed_origins.filter(|list| !list.is_empty()) { + // `*` is the opt-out, including out of a stricter instance default. + Some(list) if allows_any_origin(list) => None, + Some(list) => Some(list), + None => (!instance_default.is_empty() && !allows_any_origin(instance_default)) + .then_some(instance_default), + } +} + +/// Resolve the `Access-Control-Allow-Origin` value for a request, or `None` to +/// omit the header so the browser blocks the read. +/// +/// The request's `Origin` is echoed back only on a match against the allowlist. +/// Reflecting it unchecked is the classic way this feature turns into no +/// restriction at all. +/// +/// The comparison ignores ASCII case because a browser lowercases the scheme and +/// host it sends, so a configured `https://App.Example.com` would otherwise name +/// a real origin and still match nothing. +pub fn match_origin( + allowed_origins: &[String], + origin: Option<&http::HeaderValue>, +) -> Option { + let origin = origin?; + let origin_str = origin.to_str().ok()?; + allowed_origins + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(origin_str)) + .then(|| origin.clone()) +} + pub fn validate_authentication_method( authentication_method: AuthenticationMethod, raw_string: Option, @@ -276,6 +325,7 @@ pub async fn refresh_routers( static_asset_config AS "static_asset_config: _", wrap_body, raw_string, + allowed_origins, workspaced_route, is_static_website, error_handler_path, @@ -384,6 +434,10 @@ pub struct HttpTrigger; #[cfg(test)] mod tests { use super::*; + // Not used by the lib itself, only exercised here. + use windmill_common::global_settings::{ + validate_allowed_origins, MAX_ALLOWED_ORIGINS, MAX_ALLOWED_ORIGIN_LEN, + }; #[test] fn test_request_type_backward_compatibility() { @@ -578,6 +632,168 @@ mod tests { assert!(validate_authentication_method(AuthenticationMethod::Signature, None).is_ok()); } + // --- CORS allowed origins --- + + fn origin(value: &str) -> http::HeaderValue { + http::HeaderValue::from_str(value).unwrap() + } + + #[test] + fn test_match_origin_exact_match_echoes_request_origin() { + let allowed = vec!["https://a.com".to_string(), "https://b.com".to_string()]; + assert_eq!( + match_origin(&allowed, Some(&origin("https://b.com"))), + Some(origin("https://b.com")) + ); + } + + #[test] + fn test_match_origin_ignores_case() { + let allowed = vec!["https://App.Example.com".to_string()]; + assert_eq!( + match_origin(&allowed, Some(&origin("https://app.example.com"))), + Some(origin("https://app.example.com")) + ); + } + + #[test] + fn test_match_origin_no_match_omits_header() { + let allowed = vec!["https://a.com".to_string()]; + assert_eq!( + match_origin(&allowed, Some(&origin("https://evil.com"))), + None + ); + // A prefix of an allowed origin must not match: https://a.com.evil.com + // is a different site entirely. + assert_eq!( + match_origin(&allowed, Some(&origin("https://a.com.evil.com"))), + None + ); + } + + #[test] + fn test_wildcard_entry_means_unrestricted() { + // `*` is handled before matching: it means "no restriction", which is + // how a route opts out of a stricter instance default. + assert!(allows_any_origin(&["*".to_string()])); + assert!(allows_any_origin(&[ + "https://a.com".to_string(), + "*".to_string() + ])); + assert!(!allows_any_origin(&["https://a.com".to_string()])); + assert_eq!( + effective_allowed_origins(Some(&["*".to_string()]), &[]), + None + ); + } + + #[test] + fn test_effective_allowed_origins_prefers_the_route() { + let route = ["https://a.com".to_string()]; + let default = ["https://default.com".to_string()]; + assert_eq!( + effective_allowed_origins(Some(&route), &default), + Some(&route[..]) + ); + // No route list: the instance default applies. + assert_eq!( + effective_allowed_origins(None, &default), + Some(&default[..]) + ); + // No route list and no instance default: nothing is restricted, so the + // historical permissive behaviour is kept. + assert_eq!(effective_allowed_origins(None, &[]), None); + // A route opting out with `*` escapes a stricter instance default. + assert_eq!( + effective_allowed_origins(Some(&["*".to_string()]), &default), + None + ); + // An empty route list is not a configuration: it resolves exactly as + // `NULL` does, so it inherits the instance default rather than skipping + // it and becoming more permissive than an unset one. + assert_eq!( + effective_allowed_origins(Some(&[]), &default), + Some(&default[..]) + ); + assert_eq!(effective_allowed_origins(Some(&[]), &[]), None); + } + + #[test] + fn test_match_origin_missing_origin_header_omits_header() { + let allowed = vec!["https://a.com".to_string()]; + assert_eq!(match_origin(&allowed, None), None); + } + + #[test] + fn test_validate_allowed_origins_accepts_anything_comparable() { + // A shape that cannot match simply matches nothing, so it is the + // editor's job to warn and not this one's to refuse. What is refused is + // narrower: `null`, values that are not header-comparable, entries that + // cannot round-trip the editor's comma-separated field, and lists past + // the size a request can afford to scan. + let allowed = vec![ + "https://app.example.com".to_string(), + "http://localhost:3000".to_string(), + "http://[::1]:8080".to_string(), + "chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai".to_string(), + // Never matches, but that is the caller's problem, not an error. + "https://app.example.com/".to_string(), + "https://app.example.com:99999".to_string(), + "not-an-origin".to_string(), + "*".to_string(), + ]; + assert!(validate_allowed_origins(&allowed).is_ok()); + assert!(validate_allowed_origins(&[]).is_ok()); + } + + #[test] + fn test_parse_allowed_origins_setting_rejects_empty_array_entries() { + use windmill_common::global_settings::parse_allowed_origins_setting; + // A trailing separator in the string form is a typing artifact and is + // dropped; an empty array entry is something the caller wrote, so it + // must reach validation rather than be filtered away into an empty + // (and therefore unrestricted) default. + assert!(parse_allowed_origins_setting(Some(&serde_json::json!("https://a.com,"))).is_ok()); + assert!(parse_allowed_origins_setting(Some(&serde_json::json!([""]))).is_err()); + assert!( + parse_allowed_origins_setting(Some(&serde_json::json!(["https://a.com", ""]))).is_err() + ); + } + + #[test] + fn test_validate_allowed_origins_bounds_the_list() { + // An allowlist is scanned on every request to a restricted route, the + // unauthenticated preflight included, so its size is a cost anyone can + // trigger. + let too_many = vec!["https://a.com".to_string(); MAX_ALLOWED_ORIGINS + 1]; + assert!(validate_allowed_origins(&too_many).is_err()); + assert!(validate_allowed_origins(&too_many[..MAX_ALLOWED_ORIGINS]).is_ok()); + let too_long = format!("https://{}.com", "a".repeat(MAX_ALLOWED_ORIGIN_LEN)); + assert!(validate_allowed_origins(&[too_long]).is_err()); + } + + #[test] + fn test_validate_allowed_origins_rejects_null_and_uncomparable() { + for invalid in [ + // Every sandboxed iframe sends `Origin: null`, so allowing it would + // grant access to any page that can open one. + "null", + "NULL", // Cannot be the string an Origin header is compared against. + "https://a b.com", + "https://app.example.com ", + "https://exämple.com", + // The editor edits the list as one comma-separated field, so an + // entry carrying a comma would come back as two and widen the list. + "https://a.com,https://b.com", + "", + ] { + assert!( + validate_allowed_origins(&[invalid.to_string()]).is_err(), + "expected {invalid} to be rejected" + ); + } + } + // --- Route path regex --- #[test] diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 16f480815e..a034b0d184 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -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, + mem_peak: &mut i32, + canceled_by: &mut Option, + common_bun_proc_envs: &HashMap, + 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 = 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, diff --git a/backend/windmill-worker/src/dbt_column_index.rs b/backend/windmill-worker/src/dbt_column_index.rs index ab5853b9b6..64f148534b 100644 --- a/backend/windmill-worker/src/dbt_column_index.rs +++ b/backend/windmill-worker/src/dbt_column_index.rs @@ -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, &[ diff --git a/backend/windmill-worker/src/dbt_executor.rs b/backend/windmill-worker/src/dbt_executor.rs index 413448b644..8119897d6d 100644 --- a/backend/windmill-worker/src/dbt_executor.rs +++ b/backend/windmill-worker/src/dbt_executor.rs @@ -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, - /// 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 — diff --git a/backend/windmill-worker/src/dbt_profiles.rs b/backend/windmill-worker/src/dbt_profiles.rs index 3d96f84676..f81aea2ae5 100644 --- a/backend/windmill-worker/src/dbt_profiles.rs +++ b/backend/windmill-worker/src/dbt_profiles.rs @@ -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 { #[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, pub database: Option, /// 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 { + 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", diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index fd4f08f5fb..21b83b32ff 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -983,19 +983,21 @@ mod git_sync_check_tests { } } -/// When an auto-pull job (carrying `__git_sync_auto_pull`) fails, roll the +/// When an auto-pull job (carrying `__git_sync_auto_pull`) completes: on success, +/// record the commit as a head the workspace reflects; on failure, roll the /// optimistic `last_synced_sha` advance back to the pre-pull value so the commit /// is retried instead of being silently treated as synced, and record the failure. +/// The recorded commit is the one the pull script reports having checked out +/// (`{sha, branch}` in its result): the branch can move between the observation +/// the marker holds and the clone. A result without it falls back to the marker. #[cfg(all(feature = "enterprise", feature = "private"))] async fn maybe_reconcile_git_sync_auto_pull( db: &DB, job_id: &uuid::Uuid, workspace_id: &str, success: bool, + result: &str, ) { - if success { - return; // the optimistic synced state is already correct - } let marker: Option = match sqlx::query_scalar!( "SELECT args->'__git_sync_auto_pull' FROM v2_job WHERE id = $1", job_id @@ -1015,12 +1017,50 @@ async fn maybe_reconcile_git_sync_auto_pull( #[derive(serde::Deserialize)] struct AutoPullMarker { repo_resource_path: String, + branch: Option, + head_sha: Option, #[serde(default)] prev_synced: std::collections::HashMap, } let Ok(m) = serde_json::from_value::(marker) else { return; }; + if success { + // The optimistic synced state is already correct; record that the workspace + // now reflects the commit, which the PR CI-test check waits for. + #[derive(serde::Deserialize)] + struct PullResult { + sha: Option, + branch: Option, + } + let applied = serde_json::from_str::(result).ok(); + let branch = applied + .as_ref() + .and_then(|r| r.branch.as_deref()) + .or(m.branch.as_deref()); + let sha = applied + .as_ref() + .and_then(|r| r.sha.as_deref()) + .or(m.head_sha.as_deref()); + if let (Some(branch), Some(sha)) = (branch, sha) { + if let Err(e) = windmill_git_sync::record_synced_head( + db, + workspace_id, + &m.repo_resource_path, + branch, + sha, + "pull", + Some(*job_id), + ) + .await + { + tracing::warn!( + "git auto-pull: failed to record synced head {sha} on {branch}: {e:#}" + ); + } + } + return; + } windmill_git_sync::record_auto_pull_failure( db, workspace_id, @@ -1109,6 +1149,70 @@ fn git_sync_push_result_pushed(result: &str) -> Option { .as_bool() } +/// When a git-sync push job pushed a commit, record it as a head the workspace +/// reflects, the way a successful pull records the commit it applied. The PR +/// CI-test check waits for that record. Best-effort: failures are logged, never +/// propagated. +#[cfg(all(feature = "enterprise", feature = "private"))] +async fn maybe_record_git_sync_pushed_head( + db: &DB, + job_id: &uuid::Uuid, + workspace_id: &str, + result: &str, +) { + #[derive(serde::Deserialize)] + struct PushResult { + pushed: bool, + sha: Option, + branch: Option, + #[serde(default)] + rebased: bool, + } + let Ok(PushResult { pushed: true, sha: Some(sha), branch: Some(branch), rebased }) = + serde_json::from_str::(result) + else { + return; + }; + // A push that had to rebase sits on commits this workspace has not pulled, so the + // pushed head is not something it reflects yet; the pull those commits trigger + // records the head once they are in. + if rebased { + tracing::info!( + "git sync push: {sha} on {branch} was rebased onto unpulled commits; not recording it as synced for {workspace_id}" + ); + return; + } + let repo_path = match sqlx::query_scalar!( + "SELECT args->>'repo_url_resource_path' FROM v2_job WHERE id = $1", + job_id + ) + .fetch_optional(db) + .await + { + Ok(Some(Some(p))) => p, + Ok(_) => return, + Err(e) => { + tracing::error!("git sync push: failed to read job args: {e:#}"); + return; + } + }; + if let Err(e) = windmill_git_sync::record_synced_head( + db, + workspace_id, + &repo_path, + &branch, + &sha, + "push", + Some(*job_id), + ) + .await + { + tracing::warn!( + "git sync push: failed to record pushed head {sha} on {branch} for {workspace_id}/{repo_path}: {e:#}" + ); + } +} + /// When a git-sync push job carrying `__git_sync_open_pr` succeeds, open (or /// reopen) the PR for the branch it pushed: `wm-fork//` for a fork /// deploy, `wm_deploy/**` for a promotion deploy. Runs outbound with the @@ -1707,8 +1811,26 @@ pub async fn process_completed_job( #[cfg(all(feature = "enterprise", feature = "private"))] if job.kind == JobKind::DeploymentCallback { maybe_post_git_sync_check(db, &job_id, &workspace_id, true, result.get()).await; + maybe_reconcile_git_sync_auto_pull(db, &job_id, &workspace_id, true, result.get()) + .await; + maybe_record_git_sync_pushed_head(db, &job_id, &workspace_id, result.get()).await; maybe_open_git_sync_deploy_pr(db, &job_id, &workspace_id, result.get()).await; } + // A CI test job just finished: advance any open "Windmill CI tests" PR check for + // its workspace. Detached, since concluding a check calls GitHub and this loop + // completes jobs serially; the evaluation is idempotent and the poller retries. + #[cfg(all(feature = "enterprise", feature = "private"))] + if job + .trigger_kind + .as_ref() + .is_some_and(|k| k.is(windmill_common::jobs::JobTriggerKind::CiTest)) + { + let db = db.clone(); + let w_id = workspace_id.clone(); + tokio::spawn(async move { + windmill_git_sync::evaluate_and_conclude_ci_test_checks(&db, &w_id).await + }); + } // Asset-trigger fan-out: best-effort, never propagates errors. // Internal eligibility checks gate to top-level Script/Preview runs; @@ -1819,7 +1941,20 @@ pub async fn process_completed_job( #[cfg(all(feature = "enterprise", feature = "private"))] if job.kind == JobKind::DeploymentCallback { maybe_post_git_sync_check(db, &job.id, &job.workspace_id, false, result.get()).await; - maybe_reconcile_git_sync_auto_pull(db, &job.id, &job.workspace_id, false).await; + maybe_reconcile_git_sync_auto_pull(db, &job.id, &job.workspace_id, false, "").await; + } + // A failed CI test job also settles its check; same detached advance as on success. + #[cfg(all(feature = "enterprise", feature = "private"))] + if job + .trigger_kind + .as_ref() + .is_some_and(|k| k.is(windmill_common::jobs::JobTriggerKind::CiTest)) + { + let db = db.clone(); + let w_id = job.workspace_id.clone(); + tokio::spawn(async move { + windmill_git_sync::evaluate_and_conclude_ci_test_checks(&db, &w_id).await + }); } if job.is_flow_step() { if let Some(parent_job) = job.parent_job { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 34f46307cf..31dddcaf5f 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -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, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index d5efec8ff0..4ef8c1e90b 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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.809.0"; +export const VERSION = "v1.811.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 228d0444a5..0e6b0805d2 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -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.809.0"; +export const VERSION = "1.811.1"; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 0356c14c06..987cf766ea 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -8525,6 +8525,21 @@ properties: type: boolean description: If true, passes the request body as a raw string instead of parsing as JSON + allowed_origins: + type: array + items: + type: string + description: 'Origins allowed to call this route cross-origin, matched against + the request''s Origin header (ignoring case) and echoed back on a match. When + set, the list governs both the preflight and the response, overriding any Access-Control-Allow-Origin + the runnable returns via wm_headers. Use [''*''] to opt out of any restriction, + including the http_route_default_allowed_origins instance setting. An empty + list is not a configuration and resolves exactly as null does. When null, the + instance setting applies, or Access-Control-Allow-Origin: * if it is unset. + Ignored on a static website, which has no authentication of its own and so hands + out public files: restricting which browsers may read them protects nothing + while breaking cross-origin webfonts and fetches. A single-file static asset + is not exempt, since it can carry an authentication_method.' error_handler_path: type: string description: Path to a script to run when the triggered job fails. A bare path, diff --git a/cli/test/deploy_on_behalf_of_unit.test.ts b/cli/test/deploy_on_behalf_of_unit.test.ts index 2214c0dc37..7e21b24832 100644 --- a/cli/test/deploy_on_behalf_of_unit.test.ts +++ b/cli/test/deploy_on_behalf_of_unit.test.ts @@ -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); } }); diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index 047d50ee78..ac0b775e86 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -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({ diff --git a/docs/app-policy-email-removal.md b/docs/app-policy-email-removal.md new file mode 100644 index 0000000000..fb4cf100fd --- /dev/null +++ b/docs/app-policy-email-removal.md @@ -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. diff --git a/docs/dbt-runtime.md b/docs/dbt-runtime.md index 5fec1e42f7..6f7dc5871e 100644 --- a/docs/dbt-runtime.md +++ b/docs/dbt-runtime.md @@ -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 diff --git a/docs/git-sync-pull-design.md b/docs/git-sync-pull-design.md index 8961b0f047..5f77a3f117 100644 --- a/docs/git-sync-pull-design.md +++ b/docs/git-sync-pull-design.md @@ -618,6 +618,93 @@ repo's **Environments** timeline ("Production → Deployed"). Needs opt-in / later. The check-run version is the cheap default and matches the visual Cloudflare parity without a new permission. +### Phase 7 — CI test results check (WIN-2051) — implemented + +Surfaces Windmill's own CI tests (the `// test: script/...` annotation) as a +**"Windmill CI tests"** check run on **any PR** against the tracked branch, so a customer +can mark it a **required status check** and have Windmill CI results gate the PR — +replacing the documented GitHub Action that polls `ci_test_results_batch`. GitHub App-backed +only; reuses the Phase 4 `Checks: write` grant, so no new permission. Token repos keep the +Action, and GitLab merge requests get no CI-test surface for the same reason the Phase 4 +preview lives in a note there (a commit status would fail the project's own pipeline). + +Driven by the **`pull_request` webhook** — the same event Phase 4 already reacts to — +rather than the deploy push/pull, so it's uniform across how the PR's commit came to exist +(a fork deploy that pushes `wm-fork/**` and opens the PR, or an external push that gets +pulled in). CI tests run as separate async `ci_test` jobs in the **fork workspace** the PR +corresponds to; the check reflects that fork's current results on the PR head. + +- **State** — `git_sync_ci_test_check(workspace_id, repo_resource_path, head_sha)` (new + table). `workspace_id` is the **fork** whose `ci_test` jobs the check reflects; + `repo_resource_path` the repository (a fork can sync several, and two can hold the same + commit); `poster_workspace_id` is the **parent** whose GitHub-App installation posts the + run (the workspace that received the webhook and owns the repo hook). Plus `repo_url`, + `head_ref`, `check_run_id` (NULL until the create succeeds, and reset to NULL by a re-fired event + for the same head: the row is written first so a create that never gets recorded cannot + strand an in-progress run, and the poller retries any row without an id), `created_at`, + `concluded`, `conclusion`, `concluded_at`, `github_posted`. + Partial index `(workspace_id) WHERE NOT concluded OR NOT github_posted` (the live set the + hook + poller scan). +- **Open** — in the `pull_request` handler (opened/synchronize/reopened, or edited with a + base change, base = tracked): when the head lives in the base repo, resolve the fork + workspace from the head ref (reusing the fork-branch routing; + `resolve_pr_head_workspace`), persist the intent row with a null check-run id, then + `create_check_run` in_progress on `head_sha` via the parent's installation and adopt the + id (the poller retries the create from the row if it failed), then evaluate. An earlier head's open check is left to conclude on its + own (fork verdict or timeout): a late-delivered event for an old head must never touch + the current head's check. +- **Conclude** — the verdict is the head's own suite. Once the fork reflects the head (below) + and its dependency jobs settled, every CI test the fork declares is dispatched once, one + run per `ci_test_reference` row the way a deploy of that item would (`trigger_all_ci_tests`, + as the fork's owner, the user who created it, with no more reach than they have; a + missing or disabled owner concludes the check as failure; and without the per-item + debounce so a deploy-triggered run of the same test cannot supersede a suite run), and the job ids are + recorded on the synced-head row (`ci_test_job_ids`; `tests_dispatched_at` claims the + dispatch so the per-job hook and the poller queue it once, and a claim that never recorded + ids is retaken after 5 min). The verdict is exactly those runs: fail-fast on any + failed/canceled; `success` once all settle ("No CI tests" when the fork declares none); + `skipped` ignored. Nothing older, newer or workspace-wide stands in + for a head's runs, so a re-fired event reads the same runs and gets the same answer, a + test-only change is run because the suite runs on every head, and a deploy in flight in + the fork cannot feed another head's check. Runs purged by job retention reset the row so + the head is re-tested. +- **Readiness** — the suite is dispatched only once the fork reflects the head, so it does + not matter which webhook GitHub delivers first. The evidence is `git_sync_synced_head`: the + pull completion hook writes a row when a pull job succeeds (the pull script reports the + commit its clone checked out; the enqueue-time marker is the fallback), and the push + completion hook writes one from the deploy push script's `{pushed, sha, branch, rebased}` + result (a rebased push sits on unpulled commits and is not recorded). The head is ready + when the repository branch's newest row names it, so a branch reset to an older commit + waits for its re-pull; the prune keeps each repository branch's newest row so a PR reopened + at an unchanged head stays ready. This is a sync event log, deliberately apart from + `auto_pull.last_synced_sha`: that map decides whether the next poll pulls (a push must + never write it, or a commit someone else pushed under ours would be skipped) and it is + client-round-tripped settings. The check row stores `head_ref` for the lookup. Dispatch + also waits while a dependency job in the fork or a pull of the repository branch is queued + (a deploy push lands on whatever the remote held when it cloned, so a commit pushed there + from outside is in the workspace only once its pull ran), and the check fails outright if + a dependency job failed after the head's pull started (the item deployed nothing + runnable). A commit the fork never comes to reflect times out; a timeout on a repository + pinned to a sync script older than the one that reports pushed commits names that as the + reason. Needs the hub script versions that report the sha (`LATEST_GIT_SYNC_SCRIPT_PATH`, + `GIT_SYNC_PULL_SCRIPT_PATH`). +- **Drivers** — a per-`ci_test`-job completion hook (low latency) and the git-sync poller + (the backstop: retries the GitHub create/deliver, times stuck checks out after 30 min, + prunes old rows; runs after the auto-pull advisory lock is released so its GitHub calls + never extend the tick). Both call one idempotent `evaluate_and_conclude`, which claims the + decision with a guarded `UPDATE ... WHERE NOT concluded RETURNING` (exactly-once) and + decouples GitHub delivery via `github_posted` so a failed PATCH is retried, not hung. + +Invariants: only a head in the base repo can map to a workspace (a contributor fork's +branch names mean nothing here); the webhook's workspace posts through its own +installation; the timeout stops a hung test job from blocking a required check forever; +rows cascade away with either workspace. A plain feature-branch or +contributor-fork PR resolves to no fork workspace and gets an already-concluded `skipped` +check (branch protection counts `skipped` as passing, so requiring the check does not block +those PRs). A timeout on a repository pinned to a sync script older than the one that reports +pushed commits names that as the reason. Known limit (accepted for v1): the fork's status is workspace-wide (all its +tested items), which for the one-fork-per-PR model equals the PR's scope. + ## 16. Alternatives considered **Portal as webhook proxy (the rejected "option 2").** Subscribe the managed app diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c9b9350ef..cc7a8ee426 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.809.0", + "version": "1.811.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.809.0", + "version": "1.811.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 9b7df8228a..819a45ca8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.809.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", diff --git a/frontend/src/lib/actingUser.svelte.ts b/frontend/src/lib/actingUser.svelte.ts new file mode 100644 index 0000000000..30da65ff83 --- /dev/null +++ b/frontend/src/lib/actingUser.svelte.ts @@ -0,0 +1,74 @@ +import { untrack } from 'svelte' +import { fromStore } from 'svelte/store' +import { SvelteMap } from 'svelte/reactivity' +import { userStore, workspaceStore, type UserExt } from '$lib/stores' +import { getWorkspaceRole, type RoleLookup } from '$lib/user' + +/** + * The user acting in a workspace that is not necessarily the one the top nav points at — an AI + * session or a workspace-specific variant acts on a workspace the nav deliberately is not on. + * + * `$userStore` answers for the navigation workspace at no cost, exactly as every permission check + * in the app did before this hook existed — including when it holds nobody, which reads as unknown + * and refuses. Every other workspace is looked up, and an unresolved user there is `undefined`: it + * must never fall back to the navigation user, whose rights belong to another workspace. + * `canWrite`/`isOwner` refuse for an unknown user, which is the only safe answer. A caller that + * must not render that refusal as a denial asks `resolved` first. + */ +export function useActingUser(workspace: () => string | undefined) { + const navWorkspace = fromStore(workspaceStore) + const navUser = fromStore(userStore) + const looked = new SvelteMap() + // The workspace this effect last acted on, so arriving at one is distinguishable from the + // effect re-running while already there. + let asking: string | undefined + + $effect(() => { + const ws = workspace() + if (asking !== ws) { + asking = ws + // Dropped on the way *in*, not on the way out: a lookup that fails after the acting + // workspace has already moved on has no entry to clear at the moment it is left, so + // clearing it there would keep a refusal that no attempt is behind any more. + if (ws && untrack(() => looked.get(ws)?.kind) === 'lookup_failed') looked.delete(ws) + } + if (!ws || ws === navWorkspace.current) return + // Any settled answer stops the asking, a failure included — otherwise recording one + // would re-enter this effect and loop. + if (looked.has(ws)) return + untrack(() => { + // Memoized process-wide, so two components pointed at the same workspace share one + // request rather than each issuing their own. + getWorkspaceRole(ws).then((lookup) => looked.set(ws, lookup)) + }) + }) + + function userIn(ws: string | undefined): UserExt | undefined { + if (!ws) return undefined + if (ws === navWorkspace.current) return navUser.current + const lookup = looked.get(ws) + return lookup?.kind === 'resolved' ? lookup.user : undefined + } + + return { + /** The acting user in `ws`, or `undefined` when it is not known. Only workspaces this + * hook has been pointed at are looked up; the rest read as unknown. */ + in: userIn, + /** Whether `ws` has an answer at all — a user, or a lookup that came back without one. + * The navigation workspace always has one: `$userStore`, "nobody" included. */ + resolved: (ws: string | undefined): boolean => + !!ws && (ws === navWorkspace.current || looked.has(ws)), + get current(): UserExt | undefined { + return userIn(workspace()) + }, + /** Drop the lookups that came back empty so they are asked again. Arriving at a + * workspace already does this; a long-lived editor must call this too when it starts a + * fresh session on the workspace it is already on, or a `whoami` that happened to fail + * pins it to "unknown user" for as long as it stays there. */ + forgetFailures(): void { + for (const [ws, lookup] of looked) { + if (lookup.kind === 'lookup_failed') looked.delete(ws) + } + } + } +} diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index b0655590c2..99adeac6b3 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -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 = + (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 @@ {/if} + + {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} + + {/if} {#if apiTokenApps[resourceType] || !manual}
  • diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 37eef75bf5..b45d980246 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -26,7 +26,7 @@ AzureTriggerService, EmailTriggerService } from '$lib/gen' - import { superadmin, userStore, workspaceStore } from '$lib/stores' + import { superadmin, userStore, workspaceStore, type UserExt } from '$lib/stores' import { createEventDispatcher, getContext, untrack } from 'svelte' import { writable } from 'svelte/store' import { Alert, Button } from './common' @@ -85,6 +85,11 @@ * workspace when the editor operates on a workspace other than the one the * top nav points at (see the sessions preview / dev-workspace flows). */ workspaceOverride?: string + /** The user acting in `workspaceOverride`, for the owner suggestion and the folder + * write flags. Omit it to stand in the navigation `$userStore`, who is a member of + * the navigation workspace only; pass `null` for "not known (yet)", which that user + * must not answer for either. */ + actingUser?: UserExt | null /** One path that does not count as taken, for a caller creating something that may * already have written there itself — a setup flow correcting its own failed attempt. * Every other existing path is still refused. */ @@ -110,11 +115,16 @@ size = 'md', drawerOffset = 0, workspaceOverride = undefined, + actingUser = undefined, allowedExistingPath = undefined, warnOnRename = true }: Props = $props() let ws = $derived(workspaceOverride ?? $workspaceStore) + // Sole place this component falls back to the ambient user, and only for a caller that + // passed none; everything below reads `user`, so a caller acting on another workspace is + // never mixed with the navigation user's memberships. + let user = $derived(actingUser === undefined ? $userStore : (actingUser ?? undefined)) $effect.pre(() => { if (path == undefined) { @@ -169,17 +179,17 @@ export async function reset() { if (path == '' || path == 'u//' || path?.startsWith('tmp/') || path?.startsWith('hub/')) { - if ($lastMetaUsed == undefined || $lastMetaUsed.owner != $userStore?.username) { + if ($lastMetaUsed == undefined || $lastMetaUsed.owner != user?.username) { meta = { ownerKind: hideUser ? 'folder' : 'user', name: fullNamePlaceholder ?? random_adj() + '_' + namePlaceholder, owner: '' } if (!hideUser) { - if ($userStore?.username?.includes('@')) { - meta.owner = $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') + if (user?.username?.includes('@')) { + meta.owner = user!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') } else { - meta.owner = $userStore!.username! + meta.owner = user!.username! } } } else { @@ -229,9 +239,9 @@ .map((x) => ({ name: x, write: - $userStore?.folders?.includes(x) == true || - ($userStore?.is_admin ?? false) || - ($userStore?.is_super_admin ?? false) + user?.folders?.includes(x) == true || + (user?.is_admin ?? false) || + (user?.is_super_admin ?? false) })) ) } @@ -423,7 +433,7 @@ }) }) $effect.pre(() => { - if (ws && $userStore) { + if (ws && user) { untrack(() => { loadFolders() initPath() @@ -506,7 +516,7 @@ } else { // 'group' is unreachable here (Select only offers user/folder) // but validateName still accepts it for forward-compat. - meta.owner = $userStore?.username?.split('@')[0] ?? '' + meta.owner = user?.username?.split('@')[0] ?? '' } } } @@ -520,7 +530,7 @@
    {#if meta.ownerKind === 'user'} {@const userOwnerDisabled = - disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing} + disabled || !($superadmin || (user?.is_admin ?? false)) || disableEditing}