mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.809.0"
|
||||
".": "1.811.1"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+87
@@ -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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "32b4d1fe69fd219a5931fdd63cb2b0cb7770950e5c7fa6128bc9d2b2abcc7f2f"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of_email' = $2 AND (value->'policy'->>'on_behalf_of' IS NULL OR value->'policy'->>'on_behalf_of' NOT LIKE 'g/%')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3721bd6524ea48a1068ee8013bcc1aeca1b9fe784336fabb71ce13bdb58839da"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+84
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ws_specific\n WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "61caaa5c4ae62f618ba18f36330bbf58ff58e8c448874ffc9ca466165e545878"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text))\n WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "79d3bbd278c34734ce5babc3be852898bb83047f3563f789753e8fec030d301f"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+37
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb('u/' || $1))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "98cb765a480dac8a27ecad5df26c26f3dd6ff9aa87293ca671be32fd1305a73a"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "99194b850cb30d174c8d99303f6ed693e011a89aefb3a0e239630e6950dca5cd"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH survivors AS (\n SELECT value::text AS rendered FROM resource\n WHERE workspace_id = $1 AND NOT (path = ANY($2::text[]))\n )\n SELECT v.path FROM unnest($3::text[]) AS v(path)\n WHERE EXISTS (\n SELECT 1 FROM survivors s\n WHERE strpos(s.rendered, '\"$var:' || v.path || '\"') > 0\n OR strpos(s.rendered, '\"$jsonvar:' || v.path || '\"') > 0\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a7b6731427d51eb34744ca6a39e30d02949f149901ec95323b5725911944df4d"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+86
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE app SET policy = jsonb_set(\n jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)),\n ARRAY['on_behalf_of_email'], to_jsonb($4::text)\n ) WHERE policy->>'on_behalf_of' = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bff72874d1fdee7d572e2677aea1dede87b6793f92af884a023253a0ffc3a905"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+185
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)))\n WHERE typ IN ('app', 'raw_app')\n AND value->'policy'->>'on_behalf_of' = $2\n AND value->'policy'->>'on_behalf_of_email' = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d599e8058e96f3708cf6af2cc1a2ea3d912ec703687f6ce52d2419a410b1599e"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d88b1c445acc5375e9c543dcec81059d0e7018a6cea79ba743693098d223edf5"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_admin, operator, email FROM usr where username = $1 AND workspace_id = $2 AND disabled = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operator",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dada076fe622e9902606bf95f9e6df004f8cb7091588309a1f484b91fe7183fc"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+66
@@ -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"
|
||||
}
|
||||
Generated
+102
-100
@@ -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
@@ -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 @@
|
||||
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();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- A workspace whose auto-pulled repository last recorded a head-check failure while
|
||||
-- already synced to head "aaa": the state a recovery write is decided on.
|
||||
|
||||
INSERT INTO workspace (id, name, owner) VALUES ('ap-ws', 'ap-ws', 'test-user');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id, git_sync) VALUES
|
||||
('ap-ws', '{"repositories":[{"git_repo_resource_path":"$res:u/admin/repo",
|
||||
"auto_pull":{"enabled":true,"mode":"polling",
|
||||
"last_synced_sha":{"main":"aaa"},
|
||||
"last_pull_status":{"success":false,"at":1,"error":"head check failed: x"}}}]}');
|
||||
@@ -630,7 +630,7 @@ async fn test_folder_default_permissioned_as(db: Pool<Postgres>) -> anyhow::Resu
|
||||
);
|
||||
assert_eq!(
|
||||
policy["on_behalf_of_email"], "group-wm_deployers@windmill.dev",
|
||||
"app policy.on_behalf_of_email gets folder default email"
|
||||
"the stored address is derived from that principal"
|
||||
);
|
||||
|
||||
// 7b. Admin, non-matching path — acting user
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! A recorded auto-pull failure is cleared once the tracked head is observed again
|
||||
//! at the already-synced sha, and only then: the decision is taken on a snapshot,
|
||||
//! so the write must re-check the stored row rather than overwrite it.
|
||||
#![cfg(all(feature = "enterprise", feature = "private"))]
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::workspaces::AutoPullStatus;
|
||||
use windmill_git_sync::{clear_auto_pull_failure, persist_auto_pull_state};
|
||||
|
||||
const WS: &str = "ap-ws";
|
||||
const REPO: &str = "$res:u/admin/repo";
|
||||
|
||||
/// The failure the fixture records, as the poller would have read it.
|
||||
fn fixture_failure() -> AutoPullStatus {
|
||||
AutoPullStatus {
|
||||
synced_sha: None,
|
||||
at: 1,
|
||||
job_id: None,
|
||||
success: false,
|
||||
error: Some("head check failed: x".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn recovered(head: &str) -> AutoPullStatus {
|
||||
AutoPullStatus {
|
||||
synced_sha: Some(head.to_string()),
|
||||
at: 2,
|
||||
job_id: None,
|
||||
success: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stored_auto_pull(db: &Pool<Postgres>) -> anyhow::Result<serde_json::Value> {
|
||||
let git_sync: serde_json::Value =
|
||||
sqlx::query_scalar("SELECT git_sync FROM workspace_settings WHERE workspace_id = $1")
|
||||
.bind(WS)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(git_sync["repositories"][0]["auto_pull"].clone())
|
||||
}
|
||||
|
||||
/// The recovery every test below runs, decided on the fixture's failure at head
|
||||
/// "aaa": live in the first test, stale in the two that move the stored state
|
||||
/// first.
|
||||
async fn recovery_for_the_fixture_failure(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
clear_auto_pull_failure(
|
||||
db,
|
||||
WS,
|
||||
REPO,
|
||||
"main",
|
||||
"aaa",
|
||||
&fixture_failure(),
|
||||
&recovered("aaa"),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
|
||||
async fn recovery_clears_the_failure_at_the_synced_head(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
recovery_for_the_fixture_failure(&db).await?;
|
||||
|
||||
let auto_pull = stored_auto_pull(&db).await?;
|
||||
assert_eq!(auto_pull["last_pull_status"]["success"], true);
|
||||
assert!(auto_pull["last_pull_status"].get("error").is_none());
|
||||
assert_eq!(auto_pull["last_pull_status"]["synced_sha"], "aaa");
|
||||
assert_eq!(
|
||||
auto_pull["last_synced_sha"]["main"], "aaa",
|
||||
"the sha map is not part of a recovery write"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Between the poller observing head "aaa" unchanged and its recovery write, a
|
||||
/// webhook may have enqueued newer head "bbb". The stale recovery must leave that
|
||||
/// optimistic state (sha, success, job id) in place; the job's completion hook
|
||||
/// relies on it, and rolling the sha back would re-enqueue "bbb" on the next tick.
|
||||
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
|
||||
async fn stale_recovery_leaves_a_newer_state_alone(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let job_id = Uuid::new_v4();
|
||||
let advanced = AutoPullStatus {
|
||||
synced_sha: Some("bbb".to_string()),
|
||||
at: 3,
|
||||
job_id: Some(job_id),
|
||||
success: true,
|
||||
error: None,
|
||||
};
|
||||
persist_auto_pull_state(
|
||||
&db,
|
||||
WS,
|
||||
REPO,
|
||||
&HashMap::from([("main".to_string(), "bbb".to_string())]),
|
||||
&advanced,
|
||||
)
|
||||
.await?;
|
||||
|
||||
recovery_for_the_fixture_failure(&db).await?;
|
||||
|
||||
let auto_pull = stored_auto_pull(&db).await?;
|
||||
assert_eq!(auto_pull["last_synced_sha"]["main"], "bbb");
|
||||
assert_eq!(auto_pull["last_pull_status"]["synced_sha"], "bbb");
|
||||
assert_eq!(auto_pull["last_pull_status"]["job_id"], job_id.to_string());
|
||||
assert_eq!(auto_pull["last_pull_status"]["at"], 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The head can stay at "aaa" while a newer failure is recorded (a later head
|
||||
/// check, a pull job that failed). A recovery decided on the older failure must
|
||||
/// not paper over the newer one, whether it differs by timestamp or, within the
|
||||
/// same second, only by its error.
|
||||
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
|
||||
async fn stale_recovery_keeps_a_newer_failure_at_the_same_head(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let same_sha = HashMap::from([("main".to_string(), "aaa".to_string())]);
|
||||
for newer in [
|
||||
AutoPullStatus {
|
||||
at: 5,
|
||||
error: Some("head check failed: later".to_string()),
|
||||
..fixture_failure()
|
||||
},
|
||||
AutoPullStatus {
|
||||
error: Some("head check failed: same second".to_string()),
|
||||
..fixture_failure()
|
||||
},
|
||||
] {
|
||||
persist_auto_pull_state(&db, WS, REPO, &same_sha, &newer).await?;
|
||||
|
||||
recovery_for_the_fixture_failure(&db).await?;
|
||||
|
||||
let auto_pull = stored_auto_pull(&db).await?;
|
||||
assert_eq!(auto_pull["last_pull_status"]["success"], false);
|
||||
assert_eq!(auto_pull["last_pull_status"]["at"], newer.at);
|
||||
assert_eq!(
|
||||
auto_pull["last_pull_status"]["error"],
|
||||
newer.error.as_deref().unwrap()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -405,11 +405,12 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// 7. App: Admin preserves on_behalf_of
|
||||
// ========================================
|
||||
|
||||
// Principal only, so the stored address can only have come from deriving it.
|
||||
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
|
||||
.json(&new_app_with_on_behalf_of(
|
||||
"u/test-user/app_admin_preserve",
|
||||
Some("u/original-user"),
|
||||
Some("original@windmill.dev"),
|
||||
None,
|
||||
true,
|
||||
))
|
||||
.send()
|
||||
@@ -434,10 +435,24 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
Some("u/original-user"),
|
||||
"Admin should preserve app on_behalf_of"
|
||||
);
|
||||
// The address is written through from the principal, never taken from the request, so the
|
||||
// stored copy can only agree with it.
|
||||
assert_eq!(
|
||||
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
|
||||
Some("original@windmill.dev"),
|
||||
"Admin should preserve app on_behalf_of_email"
|
||||
"the stored address is derived from the principal, not the one the client sent"
|
||||
);
|
||||
let resp = authed(
|
||||
client().get(format!("{base}/apps/get/p/u/test-user/app_admin_preserve")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let returned: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(
|
||||
returned["policy"]["on_behalf_of_email"].as_str(),
|
||||
Some("original@windmill.dev"),
|
||||
"the response returns the address written through from the principal"
|
||||
);
|
||||
|
||||
// ========================================
|
||||
@@ -476,11 +491,6 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
Some("u/original-user"),
|
||||
"Deployer should preserve app on_behalf_of"
|
||||
);
|
||||
assert_eq!(
|
||||
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
|
||||
Some("original@windmill.dev"),
|
||||
"Deployer should preserve app on_behalf_of_email"
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 9. App: Non-admin cannot preserve
|
||||
@@ -518,10 +528,26 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
Some("u/test-user-2"),
|
||||
"Non-admin should have their own permissioned_as as app on_behalf_of"
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 9b. App: a policy naming two different accounts is rejected
|
||||
// ========================================
|
||||
|
||||
// This is the shape a workspace deploy produces when it carries the source
|
||||
// workspace's principal beside the target's address.
|
||||
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
|
||||
.json(&new_app_with_on_behalf_of(
|
||||
"u/test-user/app_mismatched_pair",
|
||||
Some("u/original-user"),
|
||||
Some("test2@windmill.dev"),
|
||||
true,
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
|
||||
Some("test2@windmill.dev"),
|
||||
"Non-admin should have their own email as app on_behalf_of_email"
|
||||
resp.status(),
|
||||
400,
|
||||
"a policy whose two halves name different accounts must be rejected"
|
||||
);
|
||||
|
||||
// ========================================
|
||||
@@ -1238,11 +1264,6 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
|
||||
Some("u/original-user"),
|
||||
"Admin update should preserve app on_behalf_of"
|
||||
);
|
||||
assert_eq!(
|
||||
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
|
||||
Some("original@windmill.dev"),
|
||||
"Admin update should preserve app on_behalf_of_email"
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Deployer updates with preserve flag
|
||||
@@ -1304,11 +1325,6 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
|
||||
Some("u/original-user"),
|
||||
"Deployer update should preserve app on_behalf_of"
|
||||
);
|
||||
assert_eq!(
|
||||
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
|
||||
Some("original@windmill.dev"),
|
||||
"Deployer update should preserve app on_behalf_of_email"
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Non-admin cannot preserve on update
|
||||
@@ -1370,10 +1386,62 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
|
||||
Some("u/test-user-2"),
|
||||
"Non-admin update should overwrite app on_behalf_of with their own"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A superadmin acting outside their workspaces has no `usr` row, so the per-workspace rename
|
||||
/// sweep never reaches the apps that name them. Their principal is their instance username, so
|
||||
/// without a global sweep a rename leaves those apps naming an account that resolves to nobody.
|
||||
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
||||
async fn test_rename_sweeps_external_superadmin_app_identity(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
let path = "u/original-user/app_run_by_external_superadmin";
|
||||
|
||||
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
|
||||
.json(&new_app_with_on_behalf_of(
|
||||
path,
|
||||
Some("u/superadmin-external"),
|
||||
Some("superadmin-external@windmill.dev"),
|
||||
true,
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
|
||||
Some("test2@windmill.dev"),
|
||||
"Non-admin update should overwrite app on_behalf_of_email with their own"
|
||||
resp.status(),
|
||||
201,
|
||||
"Should create app: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/users/rename/superadmin-external@windmill.dev"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "new_username": "superadmin_renamed" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "Should rename: {}", resp.text().await?);
|
||||
|
||||
let app = sqlx::query!(
|
||||
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
"test-workspace"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.policy.get("on_behalf_of").and_then(|v| v.as_str()),
|
||||
Some("u/superadmin_renamed"),
|
||||
"the rename should follow the principal an app names"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -2855,10 +2923,8 @@ async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyho
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// App: a real superadmin on_behalf_of is *allowed* at deploy (deployers may
|
||||
// deploy on behalf of any real user). The escalation is closed at execution
|
||||
// by the job-token cap, not by restricting what can be stored, so even a
|
||||
// superadmin email pinned onto an unrelated principal deploys fine here.
|
||||
// App: a real superadmin's address pinned onto an unrelated principal is a pair naming two
|
||||
// accounts, which the principal-authoritative policy refuses.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/apps/create")),
|
||||
"DEPLOYER_TOKEN",
|
||||
@@ -2873,12 +2939,14 @@ async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyho
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"a real superadmin on_behalf_of is allowed at deploy (capped at execution): {}",
|
||||
400,
|
||||
"a superadmin address beside an unrelated principal must be refused: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// App: a consistently named real superadmin identity is likewise allowed.
|
||||
// App: a real superadmin on_behalf_of is allowed at deploy when consistently named (deployers
|
||||
// may deploy on behalf of any real user); the escalation is closed at execution by the
|
||||
// job-token cap, not by restricting what can be stored.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/apps/create")),
|
||||
"DEPLOYER_TOKEN",
|
||||
|
||||
@@ -318,19 +318,35 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool<Postgres>) -> anyho
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
// ...and the one `settings/global` key on the allowlist, which the CLI reads before
|
||||
// creating a user on a git-sync push. `ws_base_url` is the control: the handler leaves
|
||||
// it as ungated as `automate_username_creation`, so only the allowlist stops it.
|
||||
// ...and the `settings/global` keys on the allowlist, which the CLI reads from a job: on a
|
||||
// git-sync push, and in `u/admin/hub_sync`. `ws_base_url` is the control: the handler
|
||||
// leaves it as ungated as these, so only the allowlist stops it.
|
||||
for key in ["automate_username_creation", "uid", "hub_base_url"] {
|
||||
let resp = authed(
|
||||
client().get(format!("{api}/settings/global/{key}")),
|
||||
&user_wm,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"WM_TOKEN must still read {key}: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
// The same hub pull reads `hub_api_secret` for a private Hub, but a secret stays out of
|
||||
// a job's reach even when the token borrows a superadmin.
|
||||
let resp = authed(
|
||||
client().get(format!("{api}/settings/global/automate_username_creation")),
|
||||
&user_wm,
|
||||
client().get(format!("{api}/settings/global/hub_api_secret")),
|
||||
&sa_wm,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"WM_TOKEN must still read automate_username_creation: {}",
|
||||
let status = resp.status().as_u16();
|
||||
assert!(
|
||||
status == 401 || status == 403,
|
||||
"superadmin WM_TOKEN must not read hub_api_secret ({status}): {}",
|
||||
resp.text().await?
|
||||
);
|
||||
let resp = authed(
|
||||
|
||||
@@ -5483,7 +5483,7 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
|
||||
|
||||
let result =
|
||||
RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None, restarted_from: None })
|
||||
.email("test2@windmill.dev")
|
||||
.as_user("test-user-2", "test2@windmill.dev")
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
|
||||
@@ -391,7 +391,8 @@ async fn test_create_resource_upsert_clears_ws_specific(db: Pool<Postgres>) -> a
|
||||
|
||||
/// Regression for GHSA-xmr2-98m6-cjf7: a token scoped only to `resources:write:<r>`
|
||||
/// must NOT use the resource-delete cascade to delete a linked secret variable it has
|
||||
/// no `variables:write` scope for.
|
||||
/// no `variables:write` scope for. The victim sits at a path the resource owns, which is
|
||||
/// the only kind the cascade reaches at all.
|
||||
#[sqlx::test(fixtures("ws_specific"))]
|
||||
async fn test_scoped_token_cannot_cascade_delete_linked_variable(
|
||||
db: Pool<Postgres>,
|
||||
@@ -406,7 +407,7 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"path": "u/test-user/victim_secret",
|
||||
"path": "u/test-user/db_victim_secret",
|
||||
"value": "hunter2",
|
||||
"is_secret": true,
|
||||
"description": ""
|
||||
@@ -421,7 +422,7 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
|
||||
)
|
||||
.json(&json!({
|
||||
"path": "u/test-user/db",
|
||||
"value": { "password": "$var:u/test-user/victim_secret" },
|
||||
"value": { "password": "$var:u/test-user/db_victim_secret" },
|
||||
"resource_type": "object"
|
||||
}))
|
||||
.send()
|
||||
@@ -443,9 +444,206 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
|
||||
resp.text().await?
|
||||
);
|
||||
assert!(
|
||||
variable_exists(&db, "test-workspace", "u/test-user/victim_secret").await?,
|
||||
variable_exists(&db, "test-workspace", "u/test-user/db_victim_secret").await?,
|
||||
"victim variable must survive the denied cascade"
|
||||
);
|
||||
// The scope check runs after the resource DELETE, so only the rollback keeps the resource
|
||||
// alive — moving the check out of the transaction would silently delete it on a 403.
|
||||
let resource_left: Option<i64> =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM resource WHERE workspace_id = $1 AND path = $2")
|
||||
.bind("test-workspace")
|
||||
.bind("u/test-user/db")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resource_left.unwrap_or(0),
|
||||
1,
|
||||
"the denied delete must roll the resource back too"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deleting a resource must not take a variable other things still need. Two gates, each
|
||||
/// with a way past the other: a variable outside the resource's own path is never its to
|
||||
/// delete, and even one it owns stays if another resource points at it.
|
||||
#[sqlx::test(fixtures("ws_specific"))]
|
||||
async fn test_resource_delete_spares_variables_it_does_not_own(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
let create_var = |path: &'static str| {
|
||||
authed(
|
||||
client().post(format!("{base}/variables/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
|
||||
.send()
|
||||
};
|
||||
let create_res = |path: &'static str, var: &'static str| {
|
||||
authed(
|
||||
client().post(format!("{base}/resources/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"value": { "password": format!("$var:{var}") },
|
||||
"resource_type": "object"
|
||||
}))
|
||||
.send()
|
||||
};
|
||||
|
||||
// A shared secret at a path of its own, and two resources reading it.
|
||||
assert_eq!(create_var("u/test-user/shared_canary").await?.status(), 201);
|
||||
assert_eq!(
|
||||
create_res("u/test-user/probe_a", "u/test-user/shared_canary")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
assert_eq!(
|
||||
create_res("u/test-user/probe_b", "u/test-user/shared_canary")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
|
||||
// A secret the resource at the same path owns, which a second resource also reads.
|
||||
assert_eq!(create_var("u/test-user/owned").await?.status(), 201);
|
||||
assert_eq!(
|
||||
create_res("u/test-user/owned", "u/test-user/owned")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
assert_eq!(
|
||||
create_res("u/test-user/borrower", "u/test-user/owned")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
|
||||
for resource in ["u/test-user/probe_a", "u/test-user/owned"] {
|
||||
let resp = authed(
|
||||
client().delete(format!("{base}/resources/delete/{resource}")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"delete {resource}: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
variable_exists(&db, "test-workspace", "u/test-user/shared_canary").await?,
|
||||
"a variable the deleted resource only referenced must survive"
|
||||
);
|
||||
assert!(
|
||||
variable_exists(&db, "test-workspace", "u/test-user/owned").await?,
|
||||
"an owned variable another resource still references must survive"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bulk cascade follows what RLS actually deleted, not what the caller asked for: a
|
||||
/// resource the request names but leaves standing neither cascades nor stops counting as a
|
||||
/// referrer. Both halves matter, and neither covers the other.
|
||||
#[sqlx::test(fixtures("ws_specific"))]
|
||||
async fn test_bulk_delete_follows_what_rls_deleted(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
let create_var = |path: &'static str| {
|
||||
authed(
|
||||
client().post(format!("{base}/variables/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
|
||||
.send()
|
||||
};
|
||||
// ws_specific so the flag assertion at the end has something to check.
|
||||
let create_res = |path: &'static str, var: &'static str| {
|
||||
authed(
|
||||
client().post(format!("{base}/resources/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"value": { "password": format!("$var:{var}") },
|
||||
"resource_type": "object",
|
||||
"ws_specific": true
|
||||
}))
|
||||
.send()
|
||||
};
|
||||
|
||||
// Private to test-user: a resource and the secret it owns.
|
||||
assert_eq!(create_var("u/test-user/hidden_pwd").await?.status(), 201);
|
||||
assert_eq!(
|
||||
create_res("u/test-user/hidden", "u/test-user/hidden_pwd")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
// test-user-2's own resource and secret, which the private resource above also reads.
|
||||
assert_eq!(create_var("u/test-user-2/own_pwd").await?.status(), 201);
|
||||
assert_eq!(
|
||||
create_res("u/test-user-2/own", "u/test-user-2/own_pwd")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
assert_eq!(
|
||||
create_res("u/test-user/reader", "u/test-user-2/own_pwd")
|
||||
.await?
|
||||
.status(),
|
||||
201
|
||||
);
|
||||
|
||||
// test-user-2 may write the private secret but has no access to its resource at all.
|
||||
sqlx::query(
|
||||
"UPDATE variable SET extra_perms = '{\"u/test-user-2\": true}'::jsonb
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/hidden_pwd'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(
|
||||
client().delete(format!("{base}/resources/delete_bulk")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&json!({
|
||||
"paths": ["u/test-user/hidden", "u/test-user-2/own", "u/test-user/reader"]
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "bulk delete: {}", resp.text().await?);
|
||||
|
||||
assert!(
|
||||
variable_exists(&db, "test-workspace", "u/test-user/hidden_pwd").await?,
|
||||
"the variable of a resource RLS refused to delete must survive"
|
||||
);
|
||||
assert!(
|
||||
variable_exists(&db, "test-workspace", "u/test-user-2/own_pwd").await?,
|
||||
"a requested resource RLS left standing still counts as a referrer"
|
||||
);
|
||||
// ws_specific has no RLS policy of its own, so clearing it by requested path rather than
|
||||
// by deleted path would quietly turn a surviving resource workspace-generic.
|
||||
assert_eq!(
|
||||
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/hidden").await?,
|
||||
1,
|
||||
"a resource RLS refused to delete must keep its ws_specific flag"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1053,6 +1053,10 @@ pub async fn fetch_api_authed_from_permissioned_as(
|
||||
db: &DB,
|
||||
username_override: Option<String>,
|
||||
) -> error::Result<ApiAuthed> {
|
||||
// Keyed by the supplied address, so an entry built for a principal's previous holder is reused
|
||||
// while that address is still supplied, until its 120s expiry: a cached dispatch address is
|
||||
// evicted sooner, an app's stored one (a username deleted then reused) may not be. Accepted;
|
||||
// the rebuild after expiry is the current holder's.
|
||||
let key = (w_id.to_string(), permissioned_as.clone(), email.clone());
|
||||
|
||||
let mut api_authed = match API_AUTHED_CACHE.get(&key) {
|
||||
@@ -1068,7 +1072,10 @@ pub async fn fetch_api_authed_from_permissioned_as(
|
||||
|
||||
let api_authed = ApiAuthed {
|
||||
username: authed.username,
|
||||
email,
|
||||
// The resolved one, not the address we were handed: that is the point of
|
||||
// `fetch_authed_from_permissioned_as` validating it against the principal's live
|
||||
// binding, and this value goes on to the job row, `job_perms` and the JWT.
|
||||
email: authed.email,
|
||||
is_admin: authed.is_admin,
|
||||
is_operator: authed.is_operator,
|
||||
groups: authed.groups,
|
||||
|
||||
@@ -1073,12 +1073,15 @@ fn scope_grants_access(
|
||||
/// the caller's own row; `email` and `allowed_domain_auto_invite` are derived from the
|
||||
/// token itself and touch no table.
|
||||
///
|
||||
/// `settings/global/automate_username_creation` is the one instance setting on the list.
|
||||
/// `get_global_setting` exempts a handful of keys from its own super-admin gate, that one
|
||||
/// among them, so the boolean is already readable by every authenticated user; it is here
|
||||
/// because the CLI reads it before creating a user during a git-sync push, which runs as a
|
||||
/// job. The other ungated keys have no such caller, so they stay confined — being ungated
|
||||
/// earns a key nothing on its own.
|
||||
/// Three instance settings are on the list. `get_global_setting` exempts a handful of keys
|
||||
/// from its own super-admin gate, these among them, so each is already readable by every
|
||||
/// authenticated user; each is here because the CLI reads it from a job:
|
||||
/// `automate_username_creation` before creating a user during a git-sync push, `uid` and
|
||||
/// `hub_base_url` when `u/admin/hub_sync` pulls resource types from the Hub. The other
|
||||
/// ungated keys have no such caller, so they stay confined — being ungated earns a key
|
||||
/// nothing on its own. Listing a gated key earns it nothing either: `require_super_admin`
|
||||
/// refuses every job token, so `hub_api_secret`, which that pull reads for a private Hub,
|
||||
/// stays out of a job's reach whatever this list says.
|
||||
///
|
||||
/// Deliberately absent, as each crosses that line: `users/list_invites` (returns the
|
||||
/// workspace ids the identity was invited to), `users/tokens/list` (credential metadata
|
||||
@@ -1096,6 +1099,8 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool {
|
||||
| "/api/users/tutorial_progress"
|
||||
| "/api/workspaces/allowed_domain_auto_invite"
|
||||
| "/api/settings/global/automate_username_creation"
|
||||
| "/api/settings/global/uid"
|
||||
| "/api/settings/global/hub_base_url"
|
||||
| "/api/docs/search"
|
||||
| "/api/docs/page"
|
||||
| "/api/integrations/hub/list"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: >
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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: {}",
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -452,6 +452,42 @@ async fn fetch_authed_from_permissioned_as_inner(
|
||||
w_id: &str,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
) -> Result<Authed> {
|
||||
// The `usr` row is the live binding between a `u/` principal and an address, and it is read
|
||||
// here anyway for the workspace role. Callers may hand us a cached address, so read it before
|
||||
// anything is granted: `super_admin` and `email_to_igroup` below are keyed on the address
|
||||
// while the role is keyed on the principal, and an address that no longer belongs to this
|
||||
// principal — a username freed and reassigned while its previous holder keeps a privileged
|
||||
// account — would mix one account's role with another's instance privileges.
|
||||
let member = match permissioned_as.split_once('/') {
|
||||
Some(("u", name)) => sqlx::query!(
|
||||
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
|
||||
workspace_id = $2 AND disabled = false",
|
||||
name,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?,
|
||||
_ => None,
|
||||
};
|
||||
let resolved_email;
|
||||
let email = match member.as_ref() {
|
||||
Some(m) => m.email.as_str(),
|
||||
// No enabled `usr` row. Resolve as `resolve_username_to_email` does: a disabled member's
|
||||
// own row still wins over the `password` superadmin fallback, so it can never resolve to
|
||||
// an unrelated superadmin who shares the username (workspace usernames are only unique per
|
||||
// workspace). Off the member path, which is why it is worth a query that path skips.
|
||||
None => match permissioned_as.split_once('/') {
|
||||
Some(("u", name)) => {
|
||||
resolved_email =
|
||||
crate::users::resolve_username_to_email(w_id, name, &mut *conn).await?;
|
||||
// No live binding at all: the supplied address stands. A cached one is at most one
|
||||
// notify poll stale; accepted, see `users::get_email_from_permissioned_as`.
|
||||
resolved_email.as_deref().unwrap_or(email)
|
||||
}
|
||||
_ => email,
|
||||
},
|
||||
};
|
||||
|
||||
let is_super_admin = permissioned_as == SUPERADMIN_SYNC_EMAIL
|
||||
|| email == SUPERADMIN_SECRET_EMAIL
|
||||
|| email == SUPERADMIN_NOTIFICATION_EMAIL
|
||||
@@ -465,22 +501,12 @@ async fn fetch_authed_from_permissioned_as_inner(
|
||||
if prefix == "u" {
|
||||
let (is_admin, is_operator) = if is_super_admin {
|
||||
(true, false)
|
||||
} else if let Some(m) = member.as_ref() {
|
||||
(m.is_admin, m.operator)
|
||||
} else {
|
||||
let r = sqlx::query!(
|
||||
"SELECT is_admin, operator FROM usr where username = $1 AND \
|
||||
workspace_id = $2 AND disabled = false",
|
||||
name,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
if let Some(r) = r {
|
||||
(r.is_admin, r.operator)
|
||||
} else {
|
||||
return Err(Error::NotFound(format!(
|
||||
"user {name} not found in workspace {w_id}"
|
||||
)));
|
||||
}
|
||||
return Err(Error::NotFound(format!(
|
||||
"user {name} not found in workspace {w_id}"
|
||||
)));
|
||||
};
|
||||
|
||||
let groups = get_groups_for_user(w_id, &name, email, &mut *conn).await?;
|
||||
|
||||
@@ -78,8 +78,6 @@ pub async fn resolve_folder_default_on_behalf_of(
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Uncached: this pair is written straight onto the runnable, where a stale address would
|
||||
// contradict the principal it is stored beside.
|
||||
let email =
|
||||
crate::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db).await?;
|
||||
Ok(Some((email, permissioned_as)))
|
||||
|
||||
@@ -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] = &[
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -91,9 +91,6 @@ async fn prefetch_cached_script_inner(
|
||||
derive_email: bool,
|
||||
) -> crate::error::Result<Script<ScriptRunnableSettingsInline>> {
|
||||
let derived_email = match script.on_behalf_of.as_deref().filter(|_| derive_email) {
|
||||
// Uncached: the client preserves this pair and sends it back, where the write path
|
||||
// validates it against an uncached lookup. A cached address would pair a live principal
|
||||
// with an address the account no longer holds, and the redeploy would be rejected.
|
||||
Some(permissioned_as) => Some(
|
||||
crate::users::get_email_from_permissioned_as_uncached(
|
||||
permissioned_as,
|
||||
|
||||
@@ -175,13 +175,19 @@ pub async fn permissioned_as_exists(
|
||||
|
||||
/// Drop a cached address so a transactional email change is visible immediately.
|
||||
///
|
||||
/// The address is derived at dispatch and feeds the instance-superadmin check and
|
||||
/// `email_to_igroup`, so serving a stale one would run jobs with the wrong authorization
|
||||
/// for up to the cache TTL.
|
||||
/// Not the thing that keeps authorization correct — `fetch_authed_from_permissioned_as`
|
||||
/// re-resolves the address before granting anything. This keeps the cache from serving an
|
||||
/// address that is merely wrong for the TTL, on reads and on what is shown.
|
||||
pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
|
||||
EMAIL_CACHE.remove(&(workspace_id.to_string(), username.to_string()));
|
||||
}
|
||||
|
||||
/// Drop this name's entry in every workspace, for the changes that know the name but not the
|
||||
/// workspace: a superadmin resolves through `password`, whose row names no workspace of its own.
|
||||
pub fn invalidate_email_cache_for_username(username: &str) {
|
||||
EMAIL_CACHE.retain(|(_workspace_id, cached_username), _| cached_username != username);
|
||||
}
|
||||
|
||||
/// Inverse of [`get_email_from_permissioned_as`]: the principal an on-behalf-of email
|
||||
/// names in this workspace, for callers that supply the email alone.
|
||||
///
|
||||
@@ -194,6 +200,14 @@ pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
|
||||
/// not a superadmin's, or a group that no longer exists. Callers then leave the identity
|
||||
/// unrecorded rather than storing a principal that cannot authenticate.
|
||||
///
|
||||
/// Known, accepted consequence of a real account winning the synthetic `group-*@windmill.dev`
|
||||
/// namespace: a group identity sent as its address alone, as a "keep target identity" workspace
|
||||
/// deploy sends it for scripts, flows and apps, comes back as the account holding that address
|
||||
/// when one exists, not as `g/*`. Such an account takes an admin to exist: a superadmin or an
|
||||
/// admin-configured identity provider to create it (the public OAuth providers only assert a
|
||||
/// `@windmill.dev` address to that domain's owner) and an admin of the target workspace to admit
|
||||
/// it, so no member can steer a group's runnables to themselves this way.
|
||||
///
|
||||
/// Reads through the non-RLS pool and authorizes nothing: callers must already be authorized
|
||||
/// for `workspace_id`.
|
||||
pub async fn permissioned_as_from_email(
|
||||
@@ -242,6 +256,33 @@ pub async fn permissioned_as_from_email(
|
||||
/// - "u/{username}" → resolve via [`resolve_username_to_email`] (cached)
|
||||
/// - "g/{group}" → "group-{group}@windmill.dev"
|
||||
/// - raw email → return as-is
|
||||
///
|
||||
/// `notify_user_email_change` evicts the key on every process for each change that can move it,
|
||||
/// at that process's next notify-event poll (`LISTEN_NEW_EVENTS_INTERVAL_SEC`, 10s by default),
|
||||
/// so a hit can still be the old address for up to one poll. The TTL caps it if an eviction is
|
||||
/// ever missed.
|
||||
///
|
||||
/// Which of the two to use is a question of how long a wrong answer lives, not of whether it is
|
||||
/// stored — both of these get stored and read back. A config row (an app policy, a schedule, a
|
||||
/// runnable) is the authority for every run that follows it, so a stale address there is
|
||||
/// permanent and invisible: those use [`get_email_from_permissioned_as_uncached`]. Job dispatch
|
||||
/// also stores its answer, and the worker reads it back to build that run's authed, but it
|
||||
/// governs one job and dies with it, so it stays here.
|
||||
///
|
||||
/// The job's own authorization does not trust the address as given:
|
||||
/// `fetch_authed_from_permissioned_as` re-resolves it from the principal's live binding, and that
|
||||
/// corrected address is what the job row and its token carry. Route an address into an `Authed`,
|
||||
/// a job row or a token without going through that function, and this cache stops being safe to
|
||||
/// read at dispatch.
|
||||
///
|
||||
/// What reads the dispatch address before that re-resolution (the quota and superadmin-exemption
|
||||
/// checks at the top of `push_inner`, a flow step's tag check) or when the principal has no live
|
||||
/// binding can act on the old address for up to one poll after a username reuse, an email change
|
||||
/// or a superadmin change. That window is accepted as the cost of keeping dispatch off the
|
||||
/// database; a consumer that cannot tolerate it must re-resolve first.
|
||||
///
|
||||
/// Reads through the non-RLS pool and authorizes nothing — callers must already be authorized
|
||||
/// for `workspace_id`.
|
||||
pub async fn get_email_from_permissioned_as<'c>(
|
||||
permissioned_as: &str,
|
||||
workspace_id: &str,
|
||||
@@ -250,13 +291,21 @@ pub async fn get_email_from_permissioned_as<'c>(
|
||||
get_email_from_permissioned_as_inner(permissioned_as, workspace_id, db, true).await
|
||||
}
|
||||
|
||||
/// [`get_email_from_permissioned_as`] without the address cache. Nothing evicts that cache
|
||||
/// across processes, so for a minute after an email change it still serves the old address —
|
||||
/// fine where the address only labels something on screen, wrong where it decides whether a
|
||||
/// write is accepted or is copied onto a job row that outlives the window.
|
||||
/// [`get_email_from_permissioned_as`] for a value about to be **persisted**.
|
||||
///
|
||||
/// Reads through the non-RLS pool and authorizes nothing, like the cached one: callers must
|
||||
/// already be authorized for `workspace_id`.
|
||||
/// The eviction is delivered by the `notify_event` poller, not synchronously, so for a few
|
||||
/// seconds after a change a replica can still serve the old address. In a config row that is
|
||||
/// permanent: the row outlives the eviction, every later run trusts it, and nothing re-derives
|
||||
/// it, so a principal and an address that name different accounts stay that way.
|
||||
///
|
||||
/// Use this for three cases, all of which end in a stored pair:
|
||||
/// - writing the address into a row;
|
||||
/// - the lookup that validates a pair before it is stored;
|
||||
/// - **reads whose result the client sends back** — a script or a workspace export hands over a
|
||||
/// principal and address together, and a redeploy validates that pair against a fresh
|
||||
/// resolution, so a stale one comes back as a rejected deploy rather than a stale display.
|
||||
///
|
||||
/// See [`get_email_from_permissioned_as`] for the dispatch case that deliberately does not.
|
||||
pub async fn get_email_from_permissioned_as_uncached<'c>(
|
||||
permissioned_as: &str,
|
||||
workspace_id: &str,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user