Merge branch 'main' into datatable-roles-redesign

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
This commit is contained in:
Diego Imbert
2026-09-14 14:56:14 +02:00
co-authored by Claude Opus 5
206 changed files with 7645 additions and 1235 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.809.0"
".": "1.811.1"
}
+54
View File
@@ -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)
+66
View File
@@ -2046,6 +2046,72 @@
- passes the account "acme"
- does not invent or guess the token's value
- id: global-test35-run-deployed-flow-with-form
prompt: |-
Run the deployed flow `f/evals/global/notify_customer` for me — the customer is `acme`.
initial: ai_evals/fixtures/frontend/global/initial/notify_customer_flow.json
runtime:
maxTurns: 8
# A session chat is where the run card has a preview pane beside it; run_flow
# itself is offered in every chat.
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- run_flow
# A draft may declare different arguments than the deployed version being run, so
# the names to prefill have to come from the deployed schema.
- read_workspace_item
forbiddenToolsUsed:
- test_run_flow
- call_api_endpoint
- write_flow
- deploy_workspace_item
# An empty form pushes the work back onto the user, so the prefill is part of
# what the tool is for.
toolCallArgs:
- tool: run_flow
field: args.customer
stringIncludesAnyOf:
- acme
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
skipJudge: true
judgeChecklist:
- runs the deployed flow through run_flow rather than a preview test run or a raw API endpoint
- passes the customer "acme" so the confirmation form comes up prefilled
- id: global-test36-draft-flow-test-run-not-deployed
prompt: |-
Update the `calculate_total` step of `f/evals/global/process_invoice` so it applies 8% tax and
returns `subtotal`, `tax` and `total`, then run it to check it works.
Keep it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/process_invoice
toolExpect:
# A one-step flow is as well checked by running the step as the whole flow, so both
# count: what matters is that the run is against the draft.
requiredToolsAnyOf:
- [test_run_flow, test_run_step]
# The draft is what the user asked to check, and run_flow would run the deployed
# version instead — the edit would not be in what ran.
forbiddenToolsUsed:
- run_flow
- call_api_endpoint
- deploy_workspace_item
# The judge cannot observe runs, and the edit's content is already pinned by
# global-test5 on this fixture; what this case guards is where the run went.
skipJudge: true
judgeChecklist:
- creates an AI draft of f/evals/global/process_invoice applying 8% tax
- does not deploy or save the draft
- id: global-undo-created-draft
prompt: |-
Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with.
@@ -0,0 +1,40 @@
{
"workspace": {
"flows": [
{
"path": "f/evals/global/notify_customer",
"summary": "Notify a customer",
"description": "Sends a notification to the named customer.",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"customer": {
"type": "string"
}
},
"required": ["customer"]
},
"value": {
"modules": [
{
"id": "notify",
"summary": "Send the notification",
"value": {
"type": "rawscript",
"language": "bun",
"content": "export async function main(customer: string) {\n return `Notified ${customer}`\n}\n",
"input_transforms": {
"customer": {
"type": "javascript",
"expr": "flow_input.customer"
}
}
}
}
]
}
}
]
}
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "32b4d1fe69fd219a5931fdd63cb2b0cb7770950e5c7fa6128bc9d2b2abcc7f2f"
}
@@ -0,0 +1,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"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of_email' = $2 AND (value->'policy'->>'on_behalf_of' IS NULL OR value->'policy'->>'on_behalf_of' NOT LIKE 'g/%')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "3721bd6524ea48a1068ee8013bcc1aeca1b9fe784336fabb71ce13bdb58839da"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ws_specific\n WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "61caaa5c4ae62f618ba18f36330bbf58ff58e8c448874ffc9ca466165e545878"
}
@@ -0,0 +1,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"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text))\n WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "79d3bbd278c34734ce5babc3be852898bb83047f3563f789753e8fec030d301f"
}
@@ -0,0 +1,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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb('u/' || $1))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "98cb765a480dac8a27ecad5df26c26f3dd6ff9aa87293ca671be32fd1305a73a"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "99194b850cb30d174c8d99303f6ed693e011a89aefb3a0e239630e6950dca5cd"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "WITH survivors AS (\n SELECT value::text AS rendered FROM resource\n WHERE workspace_id = $1 AND NOT (path = ANY($2::text[]))\n )\n SELECT v.path FROM unnest($3::text[]) AS v(path)\n WHERE EXISTS (\n SELECT 1 FROM survivors s\n WHERE strpos(s.rendered, '\"$var:' || v.path || '\"') > 0\n OR strpos(s.rendered, '\"$jsonvar:' || v.path || '\"') > 0\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "a7b6731427d51eb34744ca6a39e30d02949f149901ec95323b5725911944df4d"
}
@@ -0,0 +1,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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(\n jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)),\n ARRAY['on_behalf_of_email'], to_jsonb($4::text)\n ) WHERE policy->>'on_behalf_of' = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "bff72874d1fdee7d572e2677aea1dede87b6793f92af884a023253a0ffc3a905"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)))\n WHERE typ IN ('app', 'raw_app')\n AND value->'policy'->>'on_behalf_of' = $2\n AND value->'policy'->>'on_behalf_of_email' = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d599e8058e96f3708cf6af2cc1a2ea3d912ec703687f6ce52d2419a410b1599e"
}
@@ -0,0 +1,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"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "d88b1c445acc5375e9c543dcec81059d0e7018a6cea79ba743693098d223edf5"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_admin, operator, email FROM usr where username = $1 AND workspace_id = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "dada076fe622e9902606bf95f9e6df004f8cb7091588309a1f484b91fe7183fc"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+102 -100
View File
@@ -2095,9 +2095,9 @@ dependencies = [
[[package]]
name = "byte-unit"
version = "5.2.5"
version = "5.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0"
checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b"
dependencies = [
"rust_decimal",
"schemars 1.2.2",
@@ -2138,9 +2138,9 @@ dependencies = [
[[package]]
name = "bytemuck_derive"
version = "1.12.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
@@ -2311,9 +2311,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.5"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2733,9 +2733,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
@@ -6790,9 +6790,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
@@ -6807,18 +6807,19 @@ dependencies = [
[[package]]
name = "jiff-core"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
@@ -7307,9 +7308,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.23"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
dependencies = [
"bitflags 2.13.2",
"libc",
@@ -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",
@@ -15473,7 +15474,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.809.0"
version = "1.811.1"
dependencies = [
"lazy_static",
"serde",
@@ -15485,7 +15486,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.809.0"
version = "1.811.1"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15509,7 +15510,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.809.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15523,7 +15524,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.809.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15558,7 +15559,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.809.0"
version = "1.811.1"
dependencies = [
"chrono",
"lazy_static",
@@ -15572,7 +15573,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.809.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15591,7 +15592,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",
+2 -2
View File
@@ -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 <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
c388b61084c0c736a9a54a3890835e0af09b8d21
332893020ea80cd1e740e1ff4231eb5be3bbc048
@@ -0,0 +1 @@
DROP TABLE IF EXISTS git_sync_ci_test_check;
@@ -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;
@@ -0,0 +1,2 @@
-- Add down migration script here
ALTER TABLE http_trigger DROP COLUMN allowed_origins;
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TABLE http_trigger ADD COLUMN allowed_origins TEXT[];
@@ -0,0 +1 @@
DROP TABLE IF EXISTS git_sync_synced_head;
@@ -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;
@@ -0,0 +1,6 @@
-- Add down migration script here
-- Nothing to undo. The up migration gives a policy that only ever carried the address the
-- principal it runs as, and rewrites an address that disagreed with its principal. The previous
-- version reads both halves, so both results are correct for it too, and the addresses replaced
-- named an account other than the one the app runs as.
SELECT 1;
@@ -0,0 +1,100 @@
-- Add up migration script here
-- `policy.on_behalf_of` becomes the authority for an app's identity: the address beside it is
-- written through from it on every save, so the two can no longer name different accounts.
--
-- The address key is deliberately NOT removed here, and is still written: a replica predating
-- the derive-when-absent fallback errors outright when it is missing, which would 400 every
-- anonymous, publisher and guest app served by one that has not yet rolled over. Removing the
-- key is a follow-up, per docs/app-policy-email-removal.md.
--
-- What is left is the data written before that rule. A policy that only ever had the address has
-- no principal to run as, so give it one. A policy whose halves disagree was stored as a client
-- sent it; reads return that pair and a redeploy that keeps the identity sends it back, where the
-- pair check rejects it. So once every policy has a principal, rewrite its address from it.
-- Mirrors `users::username_to_permissioned_as`: an email-shaped username is its own principal
-- unless it contains a slash, which a reader would split on, and a legacy `group-*` username is
-- the group it names.
CREATE OR REPLACE FUNCTION pg_temp.username_to_permissioned_as(name VARCHAR)
RETURNS VARCHAR AS $$
SELECT CASE
WHEN $1 LIKE '%@%' AND $1 LIKE '%/%' THEN 'u/' || $1
WHEN $1 LIKE '%@%' THEN $1
WHEN $1 LIKE 'group-%' THEN 'g/' || substr($1, 7)
ELSE 'u/' || $1
END;
$$ LANGUAGE SQL IMMUTABLE;
-- Mirrors `users::permissioned_as_from_email`: a real account wins over the synthetic group
-- namespace, which is not reserved and may be a user's own address. `pg_temp` lives for the
-- whole session and migrations share one connection, so an identically-named helper from an
-- earlier migration is still in scope: replace it, and drop this one at the end.
CREATE OR REPLACE FUNCTION pg_temp.permissioned_as_from_email(w_id VARCHAR, email VARCHAR)
RETURNS VARCHAR AS $$
SELECT COALESCE(
(SELECT pg_temp.username_to_permissioned_as(u.username)
FROM usr u WHERE u.workspace_id = $1 AND u.email = $2),
-- A superadmin acting outside their workspaces has no usr row.
(SELECT pg_temp.username_to_permissioned_as(COALESCE(p.username, p.email))
FROM password p WHERE p.email = $2 AND p.super_admin),
(SELECT 'g/' || g.name FROM group_ g
WHERE g.workspace_id = $1
AND $2 = 'group-' || g.name || '@windmill.dev')
);
$$ LANGUAGE SQL STABLE;
-- Mirrors `users::get_email_from_permissioned_as`, except that a `u/` principal naming nobody
-- yields NULL rather than the synthetic `@unknown.windmill.dev` address, so that row is left as
-- it is instead of losing the one address it had.
CREATE OR REPLACE FUNCTION pg_temp.email_from_permissioned_as(w_id VARCHAR, principal VARCHAR)
RETURNS VARCHAR AS $$
SELECT CASE
WHEN $2 LIKE 'u/%' THEN COALESCE(
(SELECT u.email FROM usr u WHERE u.workspace_id = $1 AND u.username = substr($2, 3)),
(SELECT p.email FROM password p
WHERE (p.username = substr($2, 3) OR p.email = substr($2, 3)) AND p.super_admin
ORDER BY p.email LIMIT 1))
WHEN $2 LIKE 'g/%' THEN 'group-' || substr($2, 3) || '@windmill.dev'
ELSE $2
END;
$$ LANGUAGE SQL STABLE;
-- A policy naming only the address predates the principal being written to it.
-- A principal wider than `v2_job.permissioned_as` could not be enqueued, so it is not recorded
-- at all — the app falls back to erroring on anonymous execution until someone picks an identity
-- the deploy path accepts. Same cap and reason as the sibling migration 20260801043001.
UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'],
to_jsonb(pg_temp.permissioned_as_from_email(workspace_id, policy->>'on_behalf_of_email')))
WHERE policy->>'on_behalf_of' IS NULL
AND pg_temp.permissioned_as_from_email(workspace_id, policy->>'on_behalf_of_email') IS NOT NULL
AND length(pg_temp.permissioned_as_from_email(workspace_id, policy->>'on_behalf_of_email')) <= 55;
-- App drafts carry a copy of the policy and are deployed from it, so they need the same.
UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'],
to_jsonb(pg_temp.permissioned_as_from_email(workspace_id, value->'policy'->>'on_behalf_of_email'))))
WHERE typ IN ('app', 'raw_app')
AND value->'policy'->>'on_behalf_of' IS NULL
AND pg_temp.permissioned_as_from_email(workspace_id, value->'policy'->>'on_behalf_of_email') IS NOT NULL
AND length(pg_temp.permissioned_as_from_email(workspace_id, value->'policy'->>'on_behalf_of_email')) <= 55;
-- The address a save now writes, applied to the rows saved before. Execution already takes a `u/`
-- principal's own address, so this only changes what runs for a `g/` or bare principal, whose
-- stored address decided the superadmin flag and instance groups: those now follow the principal.
UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of_email'],
to_jsonb(pg_temp.email_from_permissioned_as(workspace_id, policy->>'on_behalf_of')))
WHERE policy->>'on_behalf_of' IS NOT NULL
AND pg_temp.email_from_permissioned_as(workspace_id, policy->>'on_behalf_of') IS NOT NULL
AND policy->>'on_behalf_of_email'
IS DISTINCT FROM pg_temp.email_from_permissioned_as(workspace_id, policy->>'on_behalf_of');
UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of_email'],
to_jsonb(pg_temp.email_from_permissioned_as(workspace_id, value->'policy'->>'on_behalf_of'))))
WHERE typ IN ('app', 'raw_app')
AND value->'policy'->>'on_behalf_of' IS NOT NULL
AND pg_temp.email_from_permissioned_as(workspace_id, value->'policy'->>'on_behalf_of') IS NOT NULL
AND value->'policy'->>'on_behalf_of_email'
IS DISTINCT FROM pg_temp.email_from_permissioned_as(workspace_id, value->'policy'->>'on_behalf_of');
DROP FUNCTION pg_temp.permissioned_as_from_email(VARCHAR, VARCHAR);
DROP FUNCTION pg_temp.email_from_permissioned_as(VARCHAR, VARCHAR);
DROP FUNCTION pg_temp.username_to_permissioned_as(VARCHAR);
@@ -0,0 +1,8 @@
-- Add down migration script here
DROP TRIGGER IF EXISTS password_superadmin_delete_trigger ON password;
DROP TRIGGER IF EXISTS password_superadmin_insert_trigger ON password;
DROP TRIGGER IF EXISTS password_superadmin_update_trigger ON password;
DROP TRIGGER IF EXISTS usr_email_update_trigger ON usr;
DROP TRIGGER IF EXISTS usr_email_change_trigger ON usr;
DROP FUNCTION IF EXISTS notify_superadmin_identity_change();
DROP FUNCTION IF EXISTS notify_usr_email_change();
@@ -0,0 +1,85 @@
-- Add up migration script here
-- Emit a notify_event so every process evicts its cached `permissioned_as` -> address mapping
-- (windmill-common EMAIL_CACHE) at its next notify-event poll, rather than serving the old
-- address for the rest of the TTL. Authorization does not rest on this:
-- `fetch_authed_from_permissioned_as` re-resolves the address from the principal's live binding.
-- SECURITY DEFINER so the INSERT runs as the function owner rather than the invoking
-- windmill_user/windmill_admin role, matching the other notify_* triggers.
CREATE OR REPLACE FUNCTION notify_usr_email_change()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO notify_event (channel, payload)
VALUES (
'notify_user_email_change',
COALESCE(NEW.workspace_id, OLD.workspace_id) || ':' || COALESCE(NEW.username, OLD.username)
);
-- A rename leaves the OLD username cached against this account's address; evict both keys.
IF TG_OP = 'UPDATE' AND NEW.username IS DISTINCT FROM OLD.username THEN
INSERT INTO notify_event (channel, payload)
VALUES ('notify_user_email_change', OLD.workspace_id || ':' || OLD.username);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- INSERT matters too: a lookup that resolved to nobody is cached as the synthetic
-- `{username}@unknown.windmill.dev`, so creating the row has to drop that entry.
CREATE TRIGGER usr_email_change_trigger
AFTER INSERT OR DELETE ON usr
FOR EACH ROW
EXECUTE FUNCTION notify_usr_email_change();
CREATE TRIGGER usr_email_update_trigger
AFTER UPDATE OF email, username ON usr
FOR EACH ROW
WHEN (OLD.email IS DISTINCT FROM NEW.email OR OLD.username IS DISTINCT FROM NEW.username)
EXECUTE FUNCTION notify_usr_email_change();
-- A superadmin acting outside their workspaces resolves through `password` instead, and that row
-- names no workspace of its own. The `*:` payload says so: the reader drops that name's entry in
-- every workspace rather than the whole cache, which would undo the caching on an instance that
-- rewrites these rows in bulk. Confined to superadmins because they are the only accounts the
-- `usr` triggers above cannot cover.
CREATE OR REPLACE FUNCTION notify_superadmin_identity_change()
RETURNS TRIGGER AS $$
DECLARE
names TEXT[] := '{}';
BEGIN
-- Every alias the principal can be spelled as: `resolve_username_to_email` matches a `u/`
-- principal against `username` OR `email`, and whichever string the caller passed is the key
-- it cached under, so one account can hold a live entry under either. Old and new of each,
-- because a change to one leaves the other's entry behind.
IF TG_OP <> 'DELETE' THEN names := names || ARRAY[NEW.username, NEW.email]; END IF;
IF TG_OP <> 'INSERT' THEN names := names || ARRAY[OLD.username, OLD.email]; END IF;
INSERT INTO notify_event (channel, payload)
SELECT DISTINCT 'notify_user_email_change', '*:' || n
FROM unnest(names) AS n
WHERE n IS NOT NULL;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- `super_admin` is half of what the fallback matches on, so gaining or losing it moves the
-- mapping as surely as the address does: a demotion leaves the real address cached where the
-- truth is now synthetic, and a promotion leaves that synthetic one cached in place of a real
-- account. `OLD.super_admin OR NEW.super_admin` is what catches both directions.
CREATE TRIGGER password_superadmin_update_trigger
AFTER UPDATE OF email, username, super_admin ON password
FOR EACH ROW
WHEN ((OLD.super_admin OR NEW.super_admin)
AND (OLD.email IS DISTINCT FROM NEW.email
OR OLD.username IS DISTINCT FROM NEW.username
OR OLD.super_admin IS DISTINCT FROM NEW.super_admin))
EXECUTE FUNCTION notify_superadmin_identity_change();
CREATE TRIGGER password_superadmin_insert_trigger
AFTER INSERT ON password
FOR EACH ROW
WHEN (NEW.super_admin)
EXECUTE FUNCTION notify_superadmin_identity_change();
CREATE TRIGGER password_superadmin_delete_trigger
AFTER DELETE ON password
FOR EACH ROW
WHEN (OLD.super_admin)
EXECUTE FUNCTION notify_superadmin_identity_change();
+1
View File
@@ -241,6 +241,7 @@
}
},
"snowflake_oauth": {
"resource_fields": ["database", "warehouse", "role", "schema"],
"connect_config_template": {
"display_name": "Snowflake",
"label": "Snowflake Account Identifier",
@@ -694,6 +694,107 @@ pub fn remove_pinned_imports(code: &str) -> anyhow::Result<String> {
Ok(content)
}
/// Spans of the string literals naming a loaded module: `import`/`export … from` sources and the
/// argument of a dynamic `import()`. A `require()` call is left out: `require` is an ordinary
/// binding a script can shadow, so its argument is not known to be a module.
struct ImportSpecifierSpans(Vec<Span>);
impl Visit for ImportSpecifierSpans {
noop_visit_type!();
fn visit_import_decl(&mut self, n: &swc_ecma_ast::ImportDecl) {
self.0.push(n.src.span);
}
fn visit_export_all(&mut self, n: &swc_ecma_ast::ExportAll) {
self.0.push(n.src.span);
}
fn visit_named_export(&mut self, n: &swc_ecma_ast::NamedExport) {
if let Some(src) = &n.src {
self.0.push(src.span);
}
}
fn visit_call_expr(&mut self, n: &swc_ecma_ast::CallExpr) {
if let (swc_ecma_ast::Callee::Import(_), Some(arg)) = (&n.callee, n.args.first()) {
if let (None, Expr::Lit(Lit::Str(s))) = (arg.spread, &*arg.expr) {
self.0.push(s.span);
}
}
n.visit_children_with(self);
}
}
/// Drops the `@version` from each pinned module specifier (`pkg@1.2.3/sub` -> `pkg/sub`),
/// rewriting only the specifier literals. Unlike [`remove_pinned_imports`], the same text
/// elsewhere, such as a string the script returns, stays as written.
pub fn remove_pinned_import_specifiers(code: &str) -> anyhow::Result<String> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(
FileName::Custom("main.d.ts".into()).into(),
code.to_string(),
);
let mut tss = TsSyntax::default();
tss.tsx = true;
tss.no_early_errors = true;
let lexer = Lexer::new(
Syntax::Typescript(tss),
Default::default(),
StringInput::from(&*fm),
None,
);
let module = Parser::new_from(lexer).parse_module().map_err(|e| {
anyhow::anyhow!("Error while parsing code, it is invalid TypeScript: {e:?}")
})?;
let mut specifiers = ImportSpecifierSpans(vec![]);
specifiers.visit_module(&module);
specifiers.0.sort_by_key(|s| s.lo);
// Spans index the parsed source, which the source map stripped of any UTF-8 BOM.
let bom = if code.starts_with('\u{feff}') {
'\u{feff}'.len_utf8()
} else {
0
};
let offset =
|pos: swc_common::BytePos| pos.0.checked_sub(fm.start_pos.0).map(|o| bom + o as usize);
let mut content = String::with_capacity(code.len());
let mut copied = 0;
for span in specifiers.0 {
// A span covers the literal's quotes. One that does not land on a matching pair is left
// as written rather than risk rewriting the wrong bytes.
let (Some(open), Some(close)) = (
offset(span.lo),
offset(span.hi).and_then(|e| e.checked_sub(1)),
) else {
continue;
};
let quote = code.as_bytes().get(open);
if open >= close
|| open < copied
|| !matches!(quote, Some(b'"' | b'\''))
|| code.as_bytes().get(close) != quote
{
continue;
}
let Some(specifier) = code.get(open + 1..close) else {
continue;
};
let unpinned = IMPORTS_VERSION.captures(specifier).and_then(|x| {
x.get(1)
.map(|y| format!("{}{}", y.as_str(), x.get(2).map_or("", |z| z.as_str())))
});
if let Some(unpinned) = unpinned.filter(|u| u != specifier) {
content.push_str(&code[copied..open + 1]);
content.push_str(&unpinned);
copied = close;
}
}
content.push_str(&code[copied..]);
Ok(content)
}
fn resolve_type_ref(type_resolver: &HashMap<String, (Typ, bool)>, typ: &mut Typ) {
let mut visited = std::collections::HashSet::new();
resolve_type_ref_with_visited(type_resolver, typ, &mut visited);
@@ -4,6 +4,7 @@ mod tests {
use windmill_parser::{Arg, MainArgSignature, ObjectProperty, ObjectType, Typ};
use windmill_parser_ts::{
parse_deno_signature, parse_expr_for_imports, parse_relative_imports,
remove_pinned_import_specifiers,
};
#[test]
@@ -33,6 +34,46 @@ mod tests {
);
}
#[test]
fn test_remove_pinned_import_specifiers_rewrites_only_specifiers() {
let code = r#"// héllo
import a from "pkg@1.2.3";
import b from "@scope/pkg@^2/sub";
export * from "other@3";
import rel from "./helper";
const c = await import("dyn@4");
const require = (v: string) => v;
const d = require("req@5");
// pkg@1.2.3
export const label = "pkg@1.2.3";
"#;
assert_eq!(
remove_pinned_import_specifiers(code).unwrap(),
r#"// héllo
import a from "pkg";
import b from "@scope/pkg/sub";
export * from "other";
import rel from "./helper";
const c = await import("dyn");
const require = (v: string) => v;
const d = require("req@5");
// pkg@1.2.3
export const label = "pkg@1.2.3";
"#
);
assert_eq!(
remove_pinned_import_specifiers("\u{feff}import a from 'pkg@1';").unwrap(),
"\u{feff}import a from 'pkg';"
);
assert_eq!(
remove_pinned_import_specifiers(
"// a\r\n// b\r\nimport a from \"pkg@1\";\r\nimport b from 'x@2';"
)
.unwrap(),
"// a\r\n// b\r\nimport a from \"pkg\";\r\nimport b from 'x';"
);
}
#[test]
fn test_parse_empty_main_signature() {
let code = r#"
+24 -24
View File
@@ -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",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.809.0"
version = "1.811.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+20 -2
View File
@@ -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" => {
// `<workspace_id>:<username>`, or `*:<username>` from a `password` change, which
// knows the name but no workspace. Workspace ids can't contain ':'.
if let Some(username) = payload.strip_prefix("*:") {
tracing::info!("Superadmin identity change detected, invalidating: {username}");
windmill_common::users::invalidate_email_cache_for_username(username);
} else if let Some((workspace_id, username)) = payload.split_once(':') {
tracing::info!("User email change detected, invalidating cache: {payload}");
windmill_common::users::invalidate_email_cache(workspace_id, username);
}
}
"notify_app_policy_change" => {
// payload is `<workspace_id>:<path>`; workspace ids can't contain ':'.
if server_mode {
@@ -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");
+51 -2
View File
@@ -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<Postgres>) {
{
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<serde_jso
APP_WORKSPACED_ROUTE.store(ws_route, Ordering::Relaxed);
}
pub async fn reload_http_route_default_allowed_origins_setting(conn: &DB) -> 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<serde_json::Value>,
) -> 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
+6 -1
View File
@@ -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)
+121
View File
@@ -939,6 +939,127 @@ export function main() { return midValue(); }"#,
Ok(())
}
/// A run with local modules and no lock executes the bundle its lock generation built, which
/// kept the imported script's pin; the run must still load the one copy in node_modules, and
/// leave the script's own data alone even where it matches the pinned specifier.
#[sqlx::test(fixtures("base"))]
async fn test_bun_modules_run_loads_imported_pin_from_node_modules(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
insert_deployed_bun_script(
&db,
"f/pinned_import_modules/module",
41240002,
r#"import * as isNumber from "is-number@6.0.0";
export const ns = isNumber;
export const label = "is-number@6.0.0";"#,
)
.await;
let job = JobPayload::Code(RawCode {
content: r#"import * as isNumber from "is-number";
import { ns, label } from "/f/pinned_import_modules/module";
import { local } from "./helper";
export function main() { return [ns === isNumber, label, local()]; }"#
.into(),
path: Some("f/pinned_import_modules/main".into()),
language: ScriptLang::Bun,
modules: Some(std::collections::HashMap::from([(
"helper.ts".to_string(),
windmill_common::scripts::ScriptModule {
content: "export const local = () => 'local';".into(),
language: ScriptLang::Bun,
lock: None,
},
)])),
..RawCode::default()
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
serde_json::json!([true, "is-number@6.0.0", "local"])
);
Ok(())
}
async fn bun_dependency_lock(db: &Pool<Postgres>, port: u16, path: &str, content: &str) -> String {
let deps = RunJob::from(JobPayload::RawScriptDependencies {
script_path: path.into(),
content: content.into(),
language: ScriptLang::Bun,
})
.run_until_complete(db, false, port)
.await
.json_result()
.unwrap();
let Some(lock) = deps["lock"].as_str() else {
panic!("the dependency job returned no lock: {deps}");
};
lock.to_string()
}
/// Bundling a locked script resolves a pinned dynamic `import()` as written. Where that fails, both
/// the dependency job and a run that finds no cached bundle must still build it, from the version
/// the lock pins; where bun tolerates the failure, the bundle must stay as written.
#[sqlx::test(fixtures("base"))]
async fn test_bun_bundles_pinned_dynamic_import(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The dependency job saves the script's bundle here; the server binary creates it at startup.
std::fs::create_dir_all(&*windmill_worker::BUN_BUNDLE_CACHE_DIR)?;
const PATH: &str = "f/pinned_dynamic_import/main";
// Every `script` call draws its own nonce, so each job below misses every bundle cached before
// it, this test's included, and has to build one: a cached bundle skips the build under test.
// 4.17.20 is not npm's `latest`, so a bundle that lost the pin cannot match by accident.
let script = |body: &str| {
format!(
"export async function main() {{\n {body}\n}}\n// {}",
Uuid::new_v4()
)
};
let import = r#"const m = await import("lodash@4.17.20"); return (m.default ?? m).VERSION;"#;
let lock = bun_dependency_lock(&db, port, PATH, &script(import)).await;
let result = RunJob::from(JobPayload::Code(RawCode {
content: script(import),
path: Some(PATH.into()),
language: ScriptLang::Bun,
lock: Some(lock),
..RawCode::default()
}))
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("4.17.20"));
let tolerated = script(&format!("try {{ {import} }} catch {{ return null; }}"));
let lock = bun_dependency_lock(&db, port, PATH, &tolerated).await;
let (bundle, _) = windmill_worker::compute_bundle_local_and_remote_path(
&tolerated,
&lock,
PATH,
Some(&db),
"test-workspace",
&None,
None,
)
.await;
assert!(std::fs::read_to_string(bundle)?.contains("lodash@4.17.20"));
Ok(())
}
#[sqlx::test(fixtures("base", "bun_edge_cases"))]
async fn test_bun_shared_imports_both_styles(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
+10
View File
@@ -0,0 +1,10 @@
-- A workspace whose auto-pulled repository last recorded a head-check failure while
-- already synced to head "aaa": the state a recovery write is decided on.
INSERT INTO workspace (id, name, owner) VALUES ('ap-ws', 'ap-ws', 'test-user');
INSERT INTO workspace_settings (workspace_id, git_sync) VALUES
('ap-ws', '{"repositories":[{"git_repo_resource_path":"$res:u/admin/repo",
"auto_pull":{"enabled":true,"mode":"polling",
"last_synced_sha":{"main":"aaa"},
"last_pull_status":{"success":false,"at":1,"error":"head check failed: x"}}}]}');
@@ -630,7 +630,7 @@ async fn test_folder_default_permissioned_as(db: Pool<Postgres>) -> anyhow::Resu
);
assert_eq!(
policy["on_behalf_of_email"], "group-wm_deployers@windmill.dev",
"app policy.on_behalf_of_email gets folder default email"
"the stored address is derived from that principal"
);
// 7b. Admin, non-matching path — acting user
+143
View File
@@ -0,0 +1,143 @@
//! A recorded auto-pull failure is cleared once the tracked head is observed again
//! at the already-synced sha, and only then: the decision is taken on a snapshot,
//! so the write must re-check the stored row rather than overwrite it.
#![cfg(all(feature = "enterprise", feature = "private"))]
use sqlx::{Pool, Postgres};
use std::collections::HashMap;
use uuid::Uuid;
use windmill_common::workspaces::AutoPullStatus;
use windmill_git_sync::{clear_auto_pull_failure, persist_auto_pull_state};
const WS: &str = "ap-ws";
const REPO: &str = "$res:u/admin/repo";
/// The failure the fixture records, as the poller would have read it.
fn fixture_failure() -> AutoPullStatus {
AutoPullStatus {
synced_sha: None,
at: 1,
job_id: None,
success: false,
error: Some("head check failed: x".to_string()),
}
}
fn recovered(head: &str) -> AutoPullStatus {
AutoPullStatus {
synced_sha: Some(head.to_string()),
at: 2,
job_id: None,
success: true,
error: None,
}
}
async fn stored_auto_pull(db: &Pool<Postgres>) -> anyhow::Result<serde_json::Value> {
let git_sync: serde_json::Value =
sqlx::query_scalar("SELECT git_sync FROM workspace_settings WHERE workspace_id = $1")
.bind(WS)
.fetch_one(db)
.await?;
Ok(git_sync["repositories"][0]["auto_pull"].clone())
}
/// The recovery every test below runs, decided on the fixture's failure at head
/// "aaa": live in the first test, stale in the two that move the stored state
/// first.
async fn recovery_for_the_fixture_failure(db: &Pool<Postgres>) -> anyhow::Result<()> {
clear_auto_pull_failure(
db,
WS,
REPO,
"main",
"aaa",
&fixture_failure(),
&recovered("aaa"),
)
.await?;
Ok(())
}
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
async fn recovery_clears_the_failure_at_the_synced_head(db: Pool<Postgres>) -> anyhow::Result<()> {
recovery_for_the_fixture_failure(&db).await?;
let auto_pull = stored_auto_pull(&db).await?;
assert_eq!(auto_pull["last_pull_status"]["success"], true);
assert!(auto_pull["last_pull_status"].get("error").is_none());
assert_eq!(auto_pull["last_pull_status"]["synced_sha"], "aaa");
assert_eq!(
auto_pull["last_synced_sha"]["main"], "aaa",
"the sha map is not part of a recovery write"
);
Ok(())
}
/// Between the poller observing head "aaa" unchanged and its recovery write, a
/// webhook may have enqueued newer head "bbb". The stale recovery must leave that
/// optimistic state (sha, success, job id) in place; the job's completion hook
/// relies on it, and rolling the sha back would re-enqueue "bbb" on the next tick.
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
async fn stale_recovery_leaves_a_newer_state_alone(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
let advanced = AutoPullStatus {
synced_sha: Some("bbb".to_string()),
at: 3,
job_id: Some(job_id),
success: true,
error: None,
};
persist_auto_pull_state(
&db,
WS,
REPO,
&HashMap::from([("main".to_string(), "bbb".to_string())]),
&advanced,
)
.await?;
recovery_for_the_fixture_failure(&db).await?;
let auto_pull = stored_auto_pull(&db).await?;
assert_eq!(auto_pull["last_synced_sha"]["main"], "bbb");
assert_eq!(auto_pull["last_pull_status"]["synced_sha"], "bbb");
assert_eq!(auto_pull["last_pull_status"]["job_id"], job_id.to_string());
assert_eq!(auto_pull["last_pull_status"]["at"], 3);
Ok(())
}
/// The head can stay at "aaa" while a newer failure is recorded (a later head
/// check, a pull job that failed). A recovery decided on the older failure must
/// not paper over the newer one, whether it differs by timestamp or, within the
/// same second, only by its error.
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
async fn stale_recovery_keeps_a_newer_failure_at_the_same_head(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let same_sha = HashMap::from([("main".to_string(), "aaa".to_string())]);
for newer in [
AutoPullStatus {
at: 5,
error: Some("head check failed: later".to_string()),
..fixture_failure()
},
AutoPullStatus {
error: Some("head check failed: same second".to_string()),
..fixture_failure()
},
] {
persist_auto_pull_state(&db, WS, REPO, &same_sha, &newer).await?;
recovery_for_the_fixture_failure(&db).await?;
let auto_pull = stored_auto_pull(&db).await?;
assert_eq!(auto_pull["last_pull_status"]["success"], false);
assert_eq!(auto_pull["last_pull_status"]["at"], newer.at);
assert_eq!(
auto_pull["last_pull_status"]["error"],
newer.error.as_deref().unwrap()
);
}
Ok(())
}
+58 -1
View File
@@ -1442,7 +1442,10 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool<Postgres
let before = count_global_settings(&db).await;
let mut desired = BTreeMap::new();
desired.insert("base_url".to_string(), serde_json::json!("https://wm.example.com"));
desired.insert(
"base_url".to_string(),
serde_json::json!("https://wm.example.com"),
);
desired.insert(
"github_app_webhook_base_url".to_string(),
serde_json::json!("httpss://hooks.example.com"),
@@ -1526,3 +1529,57 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>
"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<Postgres>) {
// 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");
}
+98 -30
View File
@@ -405,11 +405,12 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
// 7. App: Admin preserves on_behalf_of
// ========================================
// Principal only, so the stored address can only have come from deriving it.
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
.json(&new_app_with_on_behalf_of(
"u/test-user/app_admin_preserve",
Some("u/original-user"),
Some("original@windmill.dev"),
None,
true,
))
.send()
@@ -434,10 +435,24 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
Some("u/original-user"),
"Admin should preserve app on_behalf_of"
);
// The address is written through from the principal, never taken from the request, so the
// stored copy can only agree with it.
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Admin should preserve app on_behalf_of_email"
"the stored address is derived from the principal, not the one the client sent"
);
let resp = authed(
client().get(format!("{base}/apps/get/p/u/test-user/app_admin_preserve")),
"SECRET_TOKEN",
)
.send()
.await?;
let returned: serde_json::Value = resp.json().await?;
assert_eq!(
returned["policy"]["on_behalf_of_email"].as_str(),
Some("original@windmill.dev"),
"the response returns the address written through from the principal"
);
// ========================================
@@ -476,11 +491,6 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
Some("u/original-user"),
"Deployer should preserve app on_behalf_of"
);
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Deployer should preserve app on_behalf_of_email"
);
// ========================================
// 9. App: Non-admin cannot preserve
@@ -518,10 +528,26 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
Some("u/test-user-2"),
"Non-admin should have their own permissioned_as as app on_behalf_of"
);
// ========================================
// 9b. App: a policy naming two different accounts is rejected
// ========================================
// This is the shape a workspace deploy produces when it carries the source
// workspace's principal beside the target's address.
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
.json(&new_app_with_on_behalf_of(
"u/test-user/app_mismatched_pair",
Some("u/original-user"),
Some("test2@windmill.dev"),
true,
))
.send()
.await?;
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("test2@windmill.dev"),
"Non-admin should have their own email as app on_behalf_of_email"
resp.status(),
400,
"a policy whose two halves name different accounts must be rejected"
);
// ========================================
@@ -1238,11 +1264,6 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
Some("u/original-user"),
"Admin update should preserve app on_behalf_of"
);
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Admin update should preserve app on_behalf_of_email"
);
// ========================================
// Deployer updates with preserve flag
@@ -1304,11 +1325,6 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
Some("u/original-user"),
"Deployer update should preserve app on_behalf_of"
);
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Deployer update should preserve app on_behalf_of_email"
);
// ========================================
// Non-admin cannot preserve on update
@@ -1370,10 +1386,62 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
Some("u/test-user-2"),
"Non-admin update should overwrite app on_behalf_of with their own"
);
Ok(())
}
/// A superadmin acting outside their workspaces has no `usr` row, so the per-workspace rename
/// sweep never reaches the apps that name them. Their principal is their instance username, so
/// without a global sweep a rename leaves those apps naming an account that resolves to nobody.
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_rename_sweeps_external_superadmin_app_identity(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let path = "u/original-user/app_run_by_external_superadmin";
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
.json(&new_app_with_on_behalf_of(
path,
Some("u/superadmin-external"),
Some("superadmin-external@windmill.dev"),
true,
))
.send()
.await?;
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("test2@windmill.dev"),
"Non-admin update should overwrite app on_behalf_of_email with their own"
resp.status(),
201,
"Should create app: {}",
resp.text().await?
);
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/users/rename/superadmin-external@windmill.dev"
)),
"SECRET_TOKEN",
)
.json(&json!({ "new_username": "superadmin_renamed" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "Should rename: {}", resp.text().await?);
let app = sqlx::query!(
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
path,
"test-workspace"
)
.fetch_one(&db)
.await?;
assert_eq!(
app.policy.get("on_behalf_of").and_then(|v| v.as_str()),
Some("u/superadmin_renamed"),
"the rename should follow the principal an app names"
);
Ok(())
@@ -2855,10 +2923,8 @@ async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyho
resp.text().await?
);
// App: a real superadmin on_behalf_of is *allowed* at deploy (deployers may
// deploy on behalf of any real user). The escalation is closed at execution
// by the job-token cap, not by restricting what can be stored, so even a
// superadmin email pinned onto an unrelated principal deploys fine here.
// App: a real superadmin's address pinned onto an unrelated principal is a pair naming two
// accounts, which the principal-authoritative policy refuses.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
@@ -2873,12 +2939,14 @@ async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyho
.await?;
assert_eq!(
resp.status(),
201,
"a real superadmin on_behalf_of is allowed at deploy (capped at execution): {}",
400,
"a superadmin address beside an unrelated principal must be refused: {}",
resp.text().await?
);
// App: a consistently named real superadmin identity is likewise allowed.
// App: a real superadmin on_behalf_of is allowed at deploy when consistently named (deployers
// may deploy on behalf of any real user); the escalation is closed at execution by the
// job-token cap, not by restricting what can be stored.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
+25 -9
View File
@@ -318,19 +318,35 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool<Postgres>) -> anyho
resp.text().await?
);
}
// ...and the one `settings/global` key on the allowlist, which the CLI reads before
// creating a user on a git-sync push. `ws_base_url` is the control: the handler leaves
// it as ungated as `automate_username_creation`, so only the allowlist stops it.
// ...and the `settings/global` keys on the allowlist, which the CLI reads from a job: on a
// git-sync push, and in `u/admin/hub_sync`. `ws_base_url` is the control: the handler
// leaves it as ungated as these, so only the allowlist stops it.
for key in ["automate_username_creation", "uid", "hub_base_url"] {
let resp = authed(
client().get(format!("{api}/settings/global/{key}")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still read {key}: {}",
resp.text().await?
);
}
// The same hub pull reads `hub_api_secret` for a private Hub, but a secret stays out of
// a job's reach even when the token borrows a superadmin.
let resp = authed(
client().get(format!("{api}/settings/global/automate_username_creation")),
&user_wm,
client().get(format!("{api}/settings/global/hub_api_secret")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still read automate_username_creation: {}",
let status = resp.status().as_u16();
assert!(
status == 401 || status == 403,
"superadmin WM_TOKEN must not read hub_api_secret ({status}): {}",
resp.text().await?
);
let resp = authed(
+1 -1
View File
@@ -5483,7 +5483,7 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
let result =
RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None, restarted_from: None })
.email("test2@windmill.dev")
.as_user("test-user-2", "test2@windmill.dev")
.run_until_complete(&db, false, server.addr.port())
.await;
+202 -4
View File
@@ -391,7 +391,8 @@ async fn test_create_resource_upsert_clears_ws_specific(db: Pool<Postgres>) -> a
/// Regression for GHSA-xmr2-98m6-cjf7: a token scoped only to `resources:write:<r>`
/// must NOT use the resource-delete cascade to delete a linked secret variable it has
/// no `variables:write` scope for.
/// no `variables:write` scope for. The victim sits at a path the resource owns, which is
/// the only kind the cascade reaches at all.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_scoped_token_cannot_cascade_delete_linked_variable(
db: Pool<Postgres>,
@@ -406,7 +407,7 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/victim_secret",
"path": "u/test-user/db_victim_secret",
"value": "hunter2",
"is_secret": true,
"description": ""
@@ -421,7 +422,7 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
)
.json(&json!({
"path": "u/test-user/db",
"value": { "password": "$var:u/test-user/victim_secret" },
"value": { "password": "$var:u/test-user/db_victim_secret" },
"resource_type": "object"
}))
.send()
@@ -443,9 +444,206 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
resp.text().await?
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/victim_secret").await?,
variable_exists(&db, "test-workspace", "u/test-user/db_victim_secret").await?,
"victim variable must survive the denied cascade"
);
// The scope check runs after the resource DELETE, so only the rollback keeps the resource
// alive — moving the check out of the transaction would silently delete it on a 403.
let resource_left: Option<i64> =
sqlx::query_scalar("SELECT COUNT(*) FROM resource WHERE workspace_id = $1 AND path = $2")
.bind("test-workspace")
.bind("u/test-user/db")
.fetch_one(&db)
.await?;
assert_eq!(
resource_left.unwrap_or(0),
1,
"the denied delete must roll the resource back too"
);
Ok(())
}
/// Deleting a resource must not take a variable other things still need. Two gates, each
/// with a way past the other: a variable outside the resource's own path is never its to
/// delete, and even one it owns stays if another resource points at it.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_resource_delete_spares_variables_it_does_not_own(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let create_var = |path: &'static str| {
authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
.send()
};
let create_res = |path: &'static str, var: &'static str| {
authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": { "password": format!("$var:{var}") },
"resource_type": "object"
}))
.send()
};
// A shared secret at a path of its own, and two resources reading it.
assert_eq!(create_var("u/test-user/shared_canary").await?.status(), 201);
assert_eq!(
create_res("u/test-user/probe_a", "u/test-user/shared_canary")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/probe_b", "u/test-user/shared_canary")
.await?
.status(),
201
);
// A secret the resource at the same path owns, which a second resource also reads.
assert_eq!(create_var("u/test-user/owned").await?.status(), 201);
assert_eq!(
create_res("u/test-user/owned", "u/test-user/owned")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/borrower", "u/test-user/owned")
.await?
.status(),
201
);
for resource in ["u/test-user/probe_a", "u/test-user/owned"] {
let resp = authed(
client().delete(format!("{base}/resources/delete/{resource}")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"delete {resource}: {}",
resp.text().await?
);
}
assert!(
variable_exists(&db, "test-workspace", "u/test-user/shared_canary").await?,
"a variable the deleted resource only referenced must survive"
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/owned").await?,
"an owned variable another resource still references must survive"
);
Ok(())
}
/// The bulk cascade follows what RLS actually deleted, not what the caller asked for: a
/// resource the request names but leaves standing neither cascades nor stops counting as a
/// referrer. Both halves matter, and neither covers the other.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_bulk_delete_follows_what_rls_deleted(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let create_var = |path: &'static str| {
authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
.send()
};
// ws_specific so the flag assertion at the end has something to check.
let create_res = |path: &'static str, var: &'static str| {
authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": { "password": format!("$var:{var}") },
"resource_type": "object",
"ws_specific": true
}))
.send()
};
// Private to test-user: a resource and the secret it owns.
assert_eq!(create_var("u/test-user/hidden_pwd").await?.status(), 201);
assert_eq!(
create_res("u/test-user/hidden", "u/test-user/hidden_pwd")
.await?
.status(),
201
);
// test-user-2's own resource and secret, which the private resource above also reads.
assert_eq!(create_var("u/test-user-2/own_pwd").await?.status(), 201);
assert_eq!(
create_res("u/test-user-2/own", "u/test-user-2/own_pwd")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/reader", "u/test-user-2/own_pwd")
.await?
.status(),
201
);
// test-user-2 may write the private secret but has no access to its resource at all.
sqlx::query(
"UPDATE variable SET extra_perms = '{\"u/test-user-2\": true}'::jsonb
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/hidden_pwd'",
)
.execute(&db)
.await?;
let resp = authed(
client().delete(format!("{base}/resources/delete_bulk")),
"SECRET_TOKEN_2",
)
.json(&json!({
"paths": ["u/test-user/hidden", "u/test-user-2/own", "u/test-user/reader"]
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "bulk delete: {}", resp.text().await?);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/hidden_pwd").await?,
"the variable of a resource RLS refused to delete must survive"
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user-2/own_pwd").await?,
"a requested resource RLS left standing still counts as a referrer"
);
// ws_specific has no RLS policy of its own, so clearing it by requested path rather than
// by deleted path would quietly turn a surviving resource workspace-generic.
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/hidden").await?,
1,
"a resource RLS refused to delete must keep its ws_specific flag"
);
Ok(())
}
+8 -1
View File
@@ -1053,6 +1053,10 @@ pub async fn fetch_api_authed_from_permissioned_as(
db: &DB,
username_override: Option<String>,
) -> error::Result<ApiAuthed> {
// Keyed by the supplied address, so an entry built for a principal's previous holder is reused
// while that address is still supplied, until its 120s expiry: a cached dispatch address is
// evicted sooner, an app's stored one (a username deleted then reused) may not be. Accepted;
// the rebuild after expiry is the current holder's.
let key = (w_id.to_string(), permissioned_as.clone(), email.clone());
let mut api_authed = match API_AUTHED_CACHE.get(&key) {
@@ -1068,7 +1072,10 @@ pub async fn fetch_api_authed_from_permissioned_as(
let api_authed = ApiAuthed {
username: authed.username,
email,
// The resolved one, not the address we were handed: that is the point of
// `fetch_authed_from_permissioned_as` validating it against the principal's live
// binding, and this value goes on to the job row, `job_perms` and the JWT.
email: authed.email,
is_admin: authed.is_admin,
is_operator: authed.is_operator,
groups: authed.groups,
+11 -6
View File
@@ -1073,12 +1073,15 @@ fn scope_grants_access(
/// the caller's own row; `email` and `allowed_domain_auto_invite` are derived from the
/// token itself and touch no table.
///
/// `settings/global/automate_username_creation` is the one instance setting on the list.
/// `get_global_setting` exempts a handful of keys from its own super-admin gate, that one
/// among them, so the boolean is already readable by every authenticated user; it is here
/// because the CLI reads it before creating a user during a git-sync push, which runs as a
/// job. The other ungated keys have no such caller, so they stay confined — being ungated
/// earns a key nothing on its own.
/// Three instance settings are on the list. `get_global_setting` exempts a handful of keys
/// from its own super-admin gate, these among them, so each is already readable by every
/// authenticated user; each is here because the CLI reads it from a job:
/// `automate_username_creation` before creating a user during a git-sync push, `uid` and
/// `hub_base_url` when `u/admin/hub_sync` pulls resource types from the Hub. The other
/// ungated keys have no such caller, so they stay confined — being ungated earns a key
/// nothing on its own. Listing a gated key earns it nothing either: `require_super_admin`
/// refuses every job token, so `hub_api_secret`, which that pull reads for a private Hub,
/// stays out of a job's reach whatever this list says.
///
/// Deliberately absent, as each crosses that line: `users/list_invites` (returns the
/// workspace ids the identity was invited to), `users/tokens/list` (credential metadata
@@ -1096,6 +1099,8 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool {
| "/api/users/tutorial_progress"
| "/api/workspaces/allowed_domain_auto_invite"
| "/api/settings/global/automate_username_creation"
| "/api/settings/global/uid"
| "/api/settings/global/hub_base_url"
| "/api/docs/search"
| "/api/docs/page"
| "/api/integrations/hub/list"
+24 -1
View File
@@ -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?,
@@ -599,3 +599,59 @@ async fn test_offboard_invalid_target(db: Pool<Postgres>) -> anyhow::Result<()>
Ok(())
}
/// A legacy member named `group-ops` canonicalizes to `g/ops`, the principal the real `ops` group
/// runs as. Offboarding the member must not hand the group's runnables to the replacement.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_offboard_group_prefixed_member_keeps_group_identities(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::raw_sql(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('ops-bot@windmill.dev', 'x', 'password', false, true, 'Ops bot', 'group-ops');
INSERT INTO usr(workspace_id, email, username, is_admin, role)
VALUES ('test-workspace', 'ops-bot@windmill.dev', 'group-ops', false, 'User');
INSERT INTO group_(workspace_id, name, summary) VALUES ('test-workspace', 'ops', '');
INSERT INTO app(workspace_id, path, summary, policy, versions, extra_perms)
VALUES ('test-workspace', 'f/shared/ops_app', '',
'{\"execution_mode\": \"publisher\", \"on_behalf_of\": \"g/ops\",
\"on_behalf_of_email\": \"group-ops@windmill.dev\"}', '{}', '{}');",
)
.execute(&db)
.await?;
let preview: serde_json::Value =
authed(client().get(ws_url(port, "offboard_preview/group-ops")))
.send()
.await?
.json()
.await?;
assert!(
preview["executing_on_behalf"]["apps"].is_null(),
"the group's apps are not the member's to reassign: {preview}"
);
let resp = authed(client().post(ws_url(port, "offboard/group-ops")))
.json(&json!({
"reassign_to": "u/test-user",
"new_on_behalf_of_user": "test-user",
"delete_user": false
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let principal: Option<String> = sqlx::query_scalar(
"SELECT policy->>'on_behalf_of' FROM app
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/ops_app'",
)
.fetch_one(&db)
.await?;
assert_eq!(principal.as_deref(), Some("g/ops"));
Ok(())
}
+11 -8
View File
@@ -332,7 +332,7 @@ async fn create_schedule(
)
.await?;
// email is still written for backwards compat with old workers that don't know about permissioned_as
let resolved_email = windmill_common::users::get_email_from_permissioned_as(
let resolved_email = windmill_common::users::get_email_from_permissioned_as_uncached(
&resolved_permissioned_as,
&w_id,
&db,
@@ -545,18 +545,14 @@ async fn edit_schedule(
reject_reserved_schedule_path(path)?;
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
let mut tx = user_db.begin(&authed).await?;
// Check schedule for error
ScheduleType::from_str(&es.schedule, es.cron_version.as_deref(), true)?;
// Validate dynamic_skip if provided
if let Some(handler_path) = &es.dynamic_skip {
validate_dynamic_skip(&mut tx, &w_id, handler_path).await?;
}
let resolved_edited_by = resolve_edited_by(&authed);
// Resolved on the (non-RLS) pool before the RLS transaction opens: the lookup mid-transaction
// would hold a second connection while `tx` is checked out.
let resolved_permissioned_as = resolve_permissioned_as(
es.permissioned_as.as_ref(),
es.preserve_permissioned_as,
@@ -568,7 +564,7 @@ async fn edit_schedule(
let resolved_email = if resolved_permissioned_as
!= windmill_common::users::username_to_permissioned_as(&authed.username)
{
windmill_common::users::get_email_from_permissioned_as(
windmill_common::users::get_email_from_permissioned_as_uncached(
&resolved_permissioned_as,
&w_id,
&db,
@@ -585,6 +581,13 @@ async fn edit_schedule(
Some(&resolved_email),
)?;
let mut tx = user_db.begin(&authed).await?;
// Validate dynamic_skip if provided
if let Some(handler_path) = &es.dynamic_skip {
validate_dynamic_skip(&mut tx, &w_id, handler_path).await?;
}
let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
let schedule = sqlx::query_as!(
+21 -6
View File
@@ -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,
@@ -1055,6 +1056,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!(
@@ -1329,11 +1336,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
{
+25 -3
View File
@@ -2160,6 +2160,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.
@@ -2337,9 +2359,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)
@@ -6351,7 +6351,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
)
@@ -6359,7 +6359,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
+27 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.809.0
version: 1.811.1
title: Windmill API
contact:
@@ -31188,6 +31188,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
@@ -31279,6 +31287,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
@@ -31377,6 +31393,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
@@ -34195,8 +34219,10 @@ components:
checked when the session is minted and again on every guest request
on_behalf_of:
type: string
description: The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity.
on_behalf_of_email:
type: string
description: Address of `on_behalf_of`, written through from it on every save and returned as stored. Optional; when absent it is derived from `on_behalf_of`. Sending it is optional too; it must name the same account as `on_behalf_of`, and a pair that disagrees is rejected.
sandbox:
type: boolean
description: >
+137 -75
View File
@@ -491,6 +491,13 @@ pub struct S3Key {
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Policy {
pub on_behalf_of: Option<String>,
/// The address `on_behalf_of` resolves to. Every write stores what the principal resolves
/// to, so it is not taken from the request except when a client names only the address —
/// which is how a cross-workspace deploy carries an identity — and it is rejected when the
/// two disagree. Optional: a policy without it executes by deriving from the principal, so
/// removing it is a change of default rather than of behavior — see
/// `docs/app-policy-email-removal.md`.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
//paths:
// - script/<path>
@@ -2461,31 +2468,37 @@ async fn create_app_internal<'a>(
}
// Resolve the on-behalf-of defaults on the (non-RLS) pool *before* opening
// the RLS transaction below: doing these lookups mid-transaction would hold
// a second simultaneous connection while `tx` is still checked out.
// a second simultaneous connection while `tx` is still checked out. The race this
// leaves with a concurrent rename or removal, including a freed username later
// rebinding the stored principal, is known and accepted: see `resolve_on_behalf_of`.
let should_preserve = app.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& app.policy.on_behalf_of.is_some();
&& (app.policy.on_behalf_of.is_some() || app.policy.on_behalf_of_email.is_some());
if !should_preserve {
let mut preserved_on_behalf_of: Option<String> = None;
if should_preserve {
app.policy.on_behalf_of = windmill_common::resolve_on_behalf_of(
app.policy.on_behalf_of_email.as_deref(),
app.policy.on_behalf_of.as_deref(),
true,
&authed,
w_id,
&db,
)
.await?;
} else {
let folder_default = if windmill_common::can_preserve_on_behalf_of(&authed) {
windmill_common::folders::resolve_folder_default_permissioned_as(&db, w_id, &app.path)
.await?
} else {
None
};
if let Some(default_permissioned_as) = folder_default {
let default_email = windmill_common::users::get_email_from_permissioned_as(
&default_permissioned_as,
w_id,
&db,
)
.await?;
app.policy.on_behalf_of = Some(default_permissioned_as);
app.policy.on_behalf_of_email = Some(default_email);
} else {
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
app.policy.on_behalf_of_email = Some(authed.email.clone());
}
app.policy.on_behalf_of =
Some(folder_default.unwrap_or_else(|| username_to_permissioned_as(&authed.username)));
}
app.policy.on_behalf_of_email = stored_on_behalf_of_email(&app.policy, w_id, &db).await?;
if should_preserve {
preserved_on_behalf_of = audited_on_behalf_of(&app.policy, &authed);
}
// Reject a forged superadmin run identity in the (possibly preserved) policy.
@@ -2621,21 +2634,17 @@ async fn create_app_internal<'a>(
None,
)
.await?;
if should_preserve {
if let Some(ref obo_email) = app.policy.on_behalf_of_email {
if obo_email != &authed.email {
audit_log(
&mut *tx,
&authed,
"apps.on_behalf_of",
ActionKind::Create,
w_id,
Some(&app.path),
Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()),
)
.await?;
}
}
if let Some(ref obo_email) = preserved_on_behalf_of {
audit_log(
&mut *tx,
&authed,
"apps.on_behalf_of",
ActionKind::Create,
w_id,
Some(&app.path),
Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()),
)
.await?;
}
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
if let Some(dm) = &app.deployment_message {
@@ -3392,19 +3401,33 @@ async fn update_app_internal<'a>(
}
}
// Reject a forged superadmin run identity in a preserved policy. Mirror the
// `should_preserve` gate below (only a preserved value is caller-controlled;
// otherwise the policy is rewritten to the deployer's own identity) and run
// it on the non-RLS pool before the transaction to avoid a second connection.
if let Some(npolicy) = ns.policy.as_ref() {
// Resolved on the (non-RLS) pool before the RLS transaction opens, for the reason
// `create_app` states, with the same known, accepted rename race (see
// `resolve_on_behalf_of`). Submitting a policy is how a deployer claims the app's execution
// identity; a source deploy that sent none claims nothing, so whoever the app already runs as
// stays.
let mut preserved_on_behalf_of: Option<String> = None;
if let Some(npolicy) = ns.policy.as_mut() {
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& npolicy.on_behalf_of.is_some();
&& (npolicy.on_behalf_of.is_some() || npolicy.on_behalf_of_email.is_some());
if should_preserve {
windmill_common::auth::validate_on_behalf_of(
npolicy.on_behalf_of.as_deref(),
npolicy.on_behalf_of = windmill_common::resolve_on_behalf_of(
npolicy.on_behalf_of_email.as_deref(),
)?;
npolicy.on_behalf_of.as_deref(),
true,
&authed,
w_id,
&db,
)
.await?;
} else {
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
}
npolicy.on_behalf_of_email = stored_on_behalf_of_email(npolicy, w_id, &db).await?;
if should_preserve {
preserved_on_behalf_of = audited_on_behalf_of(npolicy, &authed);
}
}
@@ -3437,7 +3460,6 @@ async fn update_app_internal<'a>(
reject_kind_change(path, raw_app, deployed_raw_app)?;
}
let mut preserved_on_behalf_of: Option<String> = None;
let npath = if ns.policy.is_some()
|| ns.path.is_some()
|| ns.summary.is_some()
@@ -3623,23 +3645,6 @@ async fn update_app_internal<'a>(
}
}
}
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& npolicy.on_behalf_of.is_some();
if should_preserve {
if let Some(ref obo_email) = npolicy.on_behalf_of_email {
if obo_email != &authed.email {
preserved_on_behalf_of = Some(obo_email.clone());
}
}
} else if caller_sent_policy {
// Submitting a policy is how a deployer claims the app's
// execution identity. A source deploy that sent none is not
// claiming anything, so whoever the app already runs as stays.
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
npolicy.on_behalf_of_email = Some(authed.email.clone());
}
sqlb.set(
"policy",
quote(serde_json::to_string(&json!(npolicy)).map_err(|e| {
@@ -3849,6 +3854,8 @@ fn digest(code: &str) -> String {
async fn get_on_behalf_details_from_policy_and_authed(
policy: &Policy,
opt_authed: &Option<ApiAuthed>,
w_id: &str,
db: &DB,
) -> Result<(String, String, String)> {
// A guest acts only through an app open to guests — or to everyone. A members-only
// mode means the policy changed after the session was issued. Decided here, in the
@@ -3871,7 +3878,7 @@ async fn get_on_behalf_details_from_policy_and_authed(
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
let (permissioned_as, email) = get_on_behalf_of(&policy, w_id, db).await?;
(username, permissioned_as, email)
}
// Guest runs as the publisher exactly as Publisher does; the two differ only
@@ -3885,7 +3892,7 @@ async fn get_on_behalf_details_from_policy_and_authed(
"publisher execution mode requires authentication".to_string(),
)
})?;
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
let (permissioned_as, email) = get_on_behalf_of(&policy, w_id, db).await?;
(username, permissioned_as, email)
}
ExecutionMode::Viewer => {
@@ -4243,7 +4250,7 @@ async fn execute_component(
}
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let resolved_delete_secs =
resolve_delete_after_secs(None, policy_triggerables.delete_after_secs);
@@ -4613,7 +4620,7 @@ async fn upload_s3_file_from_app(
let s3_inputs = policy.s3_inputs.as_ref().unwrap();
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let on_behalf_authed = fetch_api_authed_from_permissioned_as(
permissioned_as.clone(),
@@ -5024,7 +5031,7 @@ async fn get_on_behalf_authed_from_app(
let opt_authed = guest_caller_for_mode(opt_authed.clone(), policy.execution_mode(), path)?;
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let on_behalf_authed =
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, Some(username))
@@ -5535,7 +5542,45 @@ async fn app_load_csv_preview() -> Result<()> {
))
}
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
/// The address to store beside the principal. Derived from it, never taken from the request, so
/// the stored copy can only ever agree with the principal — the drift it used to allow is what
/// this replaces.
///
/// Written unconditionally, including for the versions that could derive it instead: a replica
/// predating that fallback fails outright when the key is absent, which would 400 every
/// anonymous, publisher and guest app for the length of a rolling deploy. The write is what
/// holds the key in place — see `docs/app-policy-email-removal.md`.
async fn stored_on_behalf_of_email(policy: &Policy, w_id: &str, db: &DB) -> Result<Option<String>> {
let Some(permissioned_as) = policy.on_behalf_of.as_deref() else {
return Ok(None);
};
Ok(Some(
windmill_common::users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db)
.await?,
))
}
/// The address to record in the `apps.on_behalf_of` audit entry: the one the app will run as,
/// when it is not the deployer's own. `None` when they match — a deployer handing an app their
/// own identity is not an on-behalf-of deploy.
///
/// Reads the address `stored_on_behalf_of_email` just resolved rather than looking it up again,
/// so the audit row and the policy row can only ever name the same account.
fn audited_on_behalf_of(policy: &Policy, authed: &ApiAuthed) -> Option<String> {
policy
.on_behalf_of_email
.as_deref()
.filter(|email| *email != authed.email)
.map(str::to_string)
}
/// The identity an anonymous, publisher or guest execution runs as.
///
/// `on_behalf_of_email` is optional: every write stores it, so it is present on anything this
/// release deployed, and it is only derived for a policy that predates that. Deriving is the
/// fallback rather than the rule so that removing the key later is a change of default, not a
/// change of behavior — see `docs/app-policy-email-removal.md`.
async fn get_on_behalf_of(policy: &Policy, w_id: &str, db: &DB) -> Result<(String, String)> {
let permissioned_as = policy
.on_behalf_of
.as_ref()
@@ -5546,16 +5591,15 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
)
})?
.to_string();
let email = policy
.on_behalf_of_email
.as_ref()
.ok_or_else(|| {
Error::BadRequest(
"on_behalf_of_email is missing in the app policy and is required for anonymous execution"
.to_string(),
)
})?
.to_string();
let email = match policy.on_behalf_of_email.as_deref() {
Some(email) => email.to_string(),
// Cached on purpose, up to one notify poll stale: the accepted dispatch case
// `get_email_from_permissioned_as` documents.
None => {
windmill_common::users::get_email_from_permissioned_as(&permissioned_as, w_id, db)
.await?
}
};
// Defence in depth against a policy that already carries a forged superadmin
// sentinel (deployed before validation existed, or copied verbatim by a
// workspace fork): the sentinels are internal-only and never a legitimate app
@@ -5702,7 +5746,25 @@ async fn build_args(
"email" => authed.as_ref().map(|a| serde_json::to_value(&a.email)),
"workspace" => Some(serde_json::to_value(&w_id)),
"groups" => authed.as_ref().map(|a| serde_json::to_value(&a.groups)),
"author" => Some(serde_json::to_value(&policy.on_behalf_of_email)),
// Same rule as `get_on_behalf_of`: the stored address, derived only when absent.
"author" => {
let author = match (
policy.on_behalf_of_email.as_deref(),
policy.on_behalf_of.as_deref(),
) {
(Some(email), _) => Some(email.to_string()),
(None, Some(permissioned_as)) => Some(
windmill_common::users::get_email_from_permissioned_as(
permissioned_as,
w_id,
db,
)
.await?,
),
(None, None) => None,
};
Some(serde_json::to_value(&author))
}
_ => {
return Err(Error::BadRequest(format!(
"context variable {} not allowed",
@@ -1265,7 +1265,7 @@ is, a different one moves it there and archives the old path"),
},
"execution_mode": {
"type": "string",
"description": "Who the app's runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Either way `anonymous`, which makes the app publicly executable, is never assumed. Possible values: viewer, publisher, anonymous"
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous"
},
"on_behalf_of": {
"type": "string"
@@ -1380,7 +1380,7 @@ is, a different one moves it there and archives the old path"),
},
"execution_mode": {
"type": "string",
"description": "Who the app's runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Either way `anonymous`, which makes the app publicly executable, is never assumed. Possible values: viewer, publisher, anonymous"
"description": "Who may open the app, and who its runnables execute as. Optional, and what omitting it means depends on the operation: creating an app defaults it to `publisher` (runs on behalf of the app's publisher and requires an authenticated viewer), while updating one keeps the mode the app is already deployed under. Neither `anonymous`, which makes the app publicly executable, nor `guest`, which opens it to anyone the identity provider authenticates, is ever assumed. A guest is only admitted where the workspace also has `guest_access_enabled`, which is checked when the session is minted and again on every guest request. Possible values: viewer, publisher, guest, anonymous"
},
"on_behalf_of": {
"type": "string"
+34 -13
View File
@@ -128,6 +128,17 @@ struct WorkspaceReassignment {
// ---- Preview helpers ----
/// The principal a departing member's runnables run as, or `None` when none of them are theirs
/// to hand over. `usr.username` is constrained to `[\w-]+`, so a member is `u/{username}`, except
/// a legacy `group-*` username, which canonicalizes to the group it names: what runs under that
/// principal runs as the group, which outlives the member. `None` binds NULL, which the
/// `on_behalf_of = $n` queries then match nowhere.
fn departing_principal(username: &str) -> Option<String> {
use windmill_common::users::{username_to_permissioned_as, PERMISSIONED_AS_GROUP_PREFIX};
let principal = username_to_permissioned_as(username);
(!principal.starts_with(PERMISSIONED_AS_GROUP_PREFIX)).then_some(principal)
}
async fn get_offboard_preview(
db: impl sqlx::PgExecutor<'_> + Copy,
w_id: &str,
@@ -136,10 +147,8 @@ async fn get_offboard_preview(
) -> Result<OffboardPreview> {
let user_prefix = format!("u/{}/%", username);
let user_owner = format!("u/{}", username);
// Same form the mutation reassigns, so preview and execution cannot disagree. `usr.username`
// is constrained to `[\w-]+`, so a member is always named `u/{username}` — the address form a
// principal can also take names an account with no `usr` row, which is nobody offboardable.
let departing = windmill_common::users::username_to_permissioned_as(username);
// Same form the mutation reassigns, so preview and execution cannot disagree.
let departing = departing_principal(username);
// ---- Owned objects (under u/{username}/) ----
let scripts = sqlx::query_scalar!(
@@ -244,17 +253,17 @@ async fn get_offboard_preview(
// ---- Operator references (not under user's path) ----
let obo_scripts = sqlx::query_scalar!(
"SELECT path FROM script WHERE on_behalf_of = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted",
&departing, &user_prefix, w_id
departing.as_deref(), &user_prefix, w_id
).fetch_all(db).await?;
let obo_flows = sqlx::query_scalar!(
"SELECT path FROM flow WHERE on_behalf_of = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived",
&departing, &user_prefix, w_id
departing.as_deref(), &user_prefix, w_id
).fetch_all(db).await?;
let obo_apps = sqlx::query_scalar!(
"SELECT path FROM app WHERE policy->>'on_behalf_of' = $1 AND NOT path LIKE $2 AND workspace_id = $3",
&user_owner, &user_prefix, w_id
departing.as_deref(), &user_prefix, w_id
).fetch_all(db).await?;
let obo_schedules = sqlx::query_scalar!(
@@ -831,7 +840,7 @@ async fn offboard_user_from_workspace<'c>(
new_permissioned_as: &str,
) -> Result<OffboardSummary> {
let new_prefix = reassign_to.to_string();
let departing = windmill_common::users::username_to_permissioned_as(username);
let departing = departing_principal(username);
// The app policy stores an address beside its principal, and script/flow keep one for the
// workers that still read it, so the replacement's is resolved here.
@@ -871,7 +880,7 @@ async fn offboard_user_from_workspace<'c>(
sqlx::query!(
"UPDATE script SET on_behalf_of = $1, on_behalf_of_email = $4 WHERE on_behalf_of = $2 AND workspace_id = $3",
new_permissioned_as,
&departing,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
@@ -912,7 +921,7 @@ async fn offboard_user_from_workspace<'c>(
sqlx::query!(
"UPDATE flow SET on_behalf_of = $1, on_behalf_of_email = $4 WHERE on_behalf_of = $2 AND workspace_id = $3",
new_permissioned_as,
&departing,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
@@ -925,7 +934,7 @@ async fn offboard_user_from_workspace<'c>(
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['on_behalf_of'], to_jsonb($1::text)), ARRAY['on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('script', 'flow') AND value->>'on_behalf_of' = $2 AND workspace_id = $3"#,
new_permissioned_as,
&departing,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
@@ -951,9 +960,21 @@ async fn offboard_user_from_workspace<'c>(
"UPDATE app SET policy = jsonb_set(
jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)),
ARRAY['on_behalf_of_email'], to_jsonb($4::text)
) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3",
) WHERE policy->>'on_behalf_of' = $2 AND workspace_id = $3",
&new_permissioned_as,
username,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
.execute(&mut **tx)
.await?;
// An app draft carries a copy of the deployed policy and is deployed from it, so it needs
// the same pair rewritten — the draft sweep above only covers scripts and flows.
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2 AND workspace_id = $3"#,
new_permissioned_as,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
+274 -10
View File
@@ -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<HttpMethod> {
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<HttpMethod> {
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<StripPath>` 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<String> {
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<HttpMethod>, allow_origin: Option<http::HeaderValue> },
/// 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<std::sync::OnceLock<CorsDecision>>);
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<HttpMethod>, 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<CorsDecision> {
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<DB>,
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<DB>,
Extension(user_db): Extension<UserDB>,
Extension(auth_cache): Extension<Arc<AuthCache>>,
Extension(cors_policy): Extension<ResolvedCorsPolicy>,
OptTokened { token }: OptTokened,
Path(route_path): Path<StripPath>,
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: {}",
+47
View File
@@ -261,6 +261,12 @@ async fn rename_user(
)));
}
let old_instance_username =
sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", user_email)
.fetch_optional(&mut *tx)
.await?
.flatten();
sqlx::query!(
"UPDATE password SET username = $1 WHERE email = $2",
ru.new_username,
@@ -269,6 +275,36 @@ async fn rename_user(
.execute(&mut *tx)
.await?;
// The per-workspace sweep below only reaches accounts with a `usr` row. A superadmin acting
// outside their workspaces has none, yet an app can name them: their principal is
// `u/{password.username}`, which this rename just moved. Matching on the address as well
// keeps a like-named member of some other workspace out of it.
if let Some(old_username) = old_instance_username.filter(|u| *u != ru.new_username) {
let old_principal = windmill_common::users::username_to_permissioned_as(&old_username);
let new_principal =
windmill_common::users::username_to_permissioned_as(&ru.new_username);
sqlx::query!(
"UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text))
WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3",
&new_principal,
&old_principal,
user_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)))
WHERE typ IN ('app', 'raw_app')
AND value->'policy'->>'on_behalf_of' = $2
AND value->'policy'->>'on_behalf_of_email' = $3"#,
&new_principal,
&old_principal,
user_email
)
.execute(&mut *tx)
.await?;
}
let workspace_usernames = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1",
&user_email
@@ -765,6 +801,17 @@ async fn update_username_in_workpsace<'c>(
.execute(&mut **tx)
.await?;
// An app draft carries a copy of the deployed policy, so the rename must reach it
// there too — same reason as the script/flow draft sweep above.
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb('u/' || $1))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3"#,
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE app SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,
@@ -149,9 +149,7 @@ async fn derive_email(
if let Some(hit) = cache.get(permissioned_as) {
return Ok(Some(hit.clone()));
}
// Uncached: the address goes into an archive a client redeploys from, and the write path
// validates the pair it sends back against an uncached lookup. The memo above still holds
// this to one query per distinct principal per export.
// The memo above holds this to one query per distinct principal per export.
let email =
windmill_common::users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db)
.await?;
+41 -15
View File
@@ -452,6 +452,42 @@ async fn fetch_authed_from_permissioned_as_inner(
w_id: &str,
conn: &mut sqlx::PgConnection,
) -> Result<Authed> {
// The `usr` row is the live binding between a `u/` principal and an address, and it is read
// here anyway for the workspace role. Callers may hand us a cached address, so read it before
// anything is granted: `super_admin` and `email_to_igroup` below are keyed on the address
// while the role is keyed on the principal, and an address that no longer belongs to this
// principal — a username freed and reassigned while its previous holder keeps a privileged
// account — would mix one account's role with another's instance privileges.
let member = match permissioned_as.split_once('/') {
Some(("u", name)) => sqlx::query!(
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id
)
.fetch_optional(&mut *conn)
.await?,
_ => None,
};
let resolved_email;
let email = match member.as_ref() {
Some(m) => m.email.as_str(),
// No enabled `usr` row. Resolve as `resolve_username_to_email` does: a disabled member's
// own row still wins over the `password` superadmin fallback, so it can never resolve to
// an unrelated superadmin who shares the username (workspace usernames are only unique per
// workspace). Off the member path, which is why it is worth a query that path skips.
None => match permissioned_as.split_once('/') {
Some(("u", name)) => {
resolved_email =
crate::users::resolve_username_to_email(w_id, name, &mut *conn).await?;
// No live binding at all: the supplied address stands. A cached one is at most one
// notify poll stale; accepted, see `users::get_email_from_permissioned_as`.
resolved_email.as_deref().unwrap_or(email)
}
_ => email,
},
};
let is_super_admin = permissioned_as == SUPERADMIN_SYNC_EMAIL
|| email == SUPERADMIN_SECRET_EMAIL
|| email == SUPERADMIN_NOTIFICATION_EMAIL
@@ -465,22 +501,12 @@ async fn fetch_authed_from_permissioned_as_inner(
if prefix == "u" {
let (is_admin, is_operator) = if is_super_admin {
(true, false)
} else if let Some(m) = member.as_ref() {
(m.is_admin, m.operator)
} else {
let r = sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id
)
.fetch_optional(&mut *conn)
.await?;
if let Some(r) = r {
(r.is_admin, r.operator)
} else {
return Err(Error::NotFound(format!(
"user {name} not found in workspace {w_id}"
)));
}
return Err(Error::NotFound(format!(
"user {name} not found in workspace {w_id}"
)));
};
let groups = get_groups_for_user(w_id, &name, email, &mut *conn).await?;
-2
View File
@@ -78,8 +78,6 @@ pub async fn resolve_folder_default_on_behalf_of(
else {
return Ok(None);
};
// Uncached: this pair is written straight onto the runnable, where a stale address would
// contradict the principal it is stored beside.
let email =
crate::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db).await?;
Ok(Some((email, permissioned_as)))
@@ -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<Vec<String>> =
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<Vec<String>> {
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::<crate::error::Result<Vec<_>>>()?,
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] = &[
+10 -2
View File
@@ -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?;
+24 -12
View File
@@ -283,14 +283,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.
@@ -298,6 +300,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>,
@@ -1914,11 +1928,9 @@ pub async fn on_behalf_of_from_permissioned_as(
let Some(permissioned_as) = permissioned_as else {
return Ok(None);
};
// Uncached: the address is copied onto the job row, where it stays for the life of the run
// and decides the superadmin flag and the instance groups. Nothing evicts the cache across
// processes, so a cached read would keep minting jobs under an address the account no longer
// holds for up to a minute after it moves.
let email = users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db).await?;
// Cached on purpose, up to one notify poll stale: the accepted dispatch case
// `get_email_from_permissioned_as` documents.
let email = users::get_email_from_permissioned_as(permissioned_as, w_id, db).await?;
Ok(Some(jobs::OnBehalfOf {
email,
permissioned_as: permissioned_as.to_string(),
-3
View File
@@ -91,9 +91,6 @@ async fn prefetch_cached_script_inner(
derive_email: bool,
) -> crate::error::Result<Script<ScriptRunnableSettingsInline>> {
let derived_email = match script.on_behalf_of.as_deref().filter(|_| derive_email) {
// Uncached: the client preserves this pair and sends it back, where the write path
// validates it against an uncached lookup. A cached address would pair a live principal
// with an address the account no longer holds, and the redeploy would be rejected.
Some(permissioned_as) => Some(
crate::users::get_email_from_permissioned_as_uncached(
permissioned_as,
+58 -9
View File
@@ -175,13 +175,19 @@ pub async fn permissioned_as_exists(
/// Drop a cached address so a transactional email change is visible immediately.
///
/// The address is derived at dispatch and feeds the instance-superadmin check and
/// `email_to_igroup`, so serving a stale one would run jobs with the wrong authorization
/// for up to the cache TTL.
/// Not the thing that keeps authorization correct — `fetch_authed_from_permissioned_as`
/// re-resolves the address before granting anything. This keeps the cache from serving an
/// address that is merely wrong for the TTL, on reads and on what is shown.
pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
EMAIL_CACHE.remove(&(workspace_id.to_string(), username.to_string()));
}
/// Drop this name's entry in every workspace, for the changes that know the name but not the
/// workspace: a superadmin resolves through `password`, whose row names no workspace of its own.
pub fn invalidate_email_cache_for_username(username: &str) {
EMAIL_CACHE.retain(|(_workspace_id, cached_username), _| cached_username != username);
}
/// Inverse of [`get_email_from_permissioned_as`]: the principal an on-behalf-of email
/// names in this workspace, for callers that supply the email alone.
///
@@ -194,6 +200,14 @@ pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
/// not a superadmin's, or a group that no longer exists. Callers then leave the identity
/// unrecorded rather than storing a principal that cannot authenticate.
///
/// Known, accepted consequence of a real account winning the synthetic `group-*@windmill.dev`
/// namespace: a group identity sent as its address alone, as a "keep target identity" workspace
/// deploy sends it for scripts, flows and apps, comes back as the account holding that address
/// when one exists, not as `g/*`. Such an account takes an admin to exist: a superadmin or an
/// admin-configured identity provider to create it (the public OAuth providers only assert a
/// `@windmill.dev` address to that domain's owner) and an admin of the target workspace to admit
/// it, so no member can steer a group's runnables to themselves this way.
///
/// Reads through the non-RLS pool and authorizes nothing: callers must already be authorized
/// for `workspace_id`.
pub async fn permissioned_as_from_email(
@@ -242,6 +256,33 @@ pub async fn permissioned_as_from_email(
/// - "u/{username}" → resolve via [`resolve_username_to_email`] (cached)
/// - "g/{group}" → "group-{group}@windmill.dev"
/// - raw email → return as-is
///
/// `notify_user_email_change` evicts the key on every process for each change that can move it,
/// at that process's next notify-event poll (`LISTEN_NEW_EVENTS_INTERVAL_SEC`, 10s by default),
/// so a hit can still be the old address for up to one poll. The TTL caps it if an eviction is
/// ever missed.
///
/// Which of the two to use is a question of how long a wrong answer lives, not of whether it is
/// stored — both of these get stored and read back. A config row (an app policy, a schedule, a
/// runnable) is the authority for every run that follows it, so a stale address there is
/// permanent and invisible: those use [`get_email_from_permissioned_as_uncached`]. Job dispatch
/// also stores its answer, and the worker reads it back to build that run's authed, but it
/// governs one job and dies with it, so it stays here.
///
/// The job's own authorization does not trust the address as given:
/// `fetch_authed_from_permissioned_as` re-resolves it from the principal's live binding, and that
/// corrected address is what the job row and its token carry. Route an address into an `Authed`,
/// a job row or a token without going through that function, and this cache stops being safe to
/// read at dispatch.
///
/// What reads the dispatch address before that re-resolution (the quota and superadmin-exemption
/// checks at the top of `push_inner`, a flow step's tag check) or when the principal has no live
/// binding can act on the old address for up to one poll after a username reuse, an email change
/// or a superadmin change. That window is accepted as the cost of keeping dispatch off the
/// database; a consumer that cannot tolerate it must re-resolve first.
///
/// Reads through the non-RLS pool and authorizes nothing — callers must already be authorized
/// for `workspace_id`.
pub async fn get_email_from_permissioned_as<'c>(
permissioned_as: &str,
workspace_id: &str,
@@ -250,13 +291,21 @@ pub async fn get_email_from_permissioned_as<'c>(
get_email_from_permissioned_as_inner(permissioned_as, workspace_id, db, true).await
}
/// [`get_email_from_permissioned_as`] without the address cache. Nothing evicts that cache
/// across processes, so for a minute after an email change it still serves the old address —
/// fine where the address only labels something on screen, wrong where it decides whether a
/// write is accepted or is copied onto a job row that outlives the window.
/// [`get_email_from_permissioned_as`] for a value about to be **persisted**.
///
/// Reads through the non-RLS pool and authorizes nothing, like the cached one: callers must
/// already be authorized for `workspace_id`.
/// The eviction is delivered by the `notify_event` poller, not synchronously, so for a few
/// seconds after a change a replica can still serve the old address. In a config row that is
/// permanent: the row outlives the eviction, every later run trusts it, and nothing re-derives
/// it, so a principal and an address that name different accounts stay that way.
///
/// Use this for three cases, all of which end in a stored pair:
/// - writing the address into a row;
/// - the lookup that validates a pair before it is stored;
/// - **reads whose result the client sends back** — a script or a workspace export hands over a
/// principal and address together, and a redeploy validates that pair against a fresh
/// resolution, so a stale one comes back as a rejected deploy rather than a stale display.
///
/// See [`get_email_from_permissioned_as`] for the dispatch case that deliberately does not.
pub async fn get_email_from_permissioned_as_uncached<'c>(
permissioned_as: &str,
workspace_id: &str,
+5 -4
View File
@@ -192,7 +192,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.
@@ -560,9 +560,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)
}

Some files were not shown because too many files have changed in this diff Show More