Merge remote-tracking branch 'origin/main' into sqs-trigger-ci-coverage

# Conflicts:
#	.github/workflows/backend-test.yml
#	backend/ee-repo-ref.txt
This commit is contained in:
Alexander Petric
2026-09-25 09:47:36 -04:00
404 changed files with 22151 additions and 12530 deletions
+1 -1
View File
@@ -319,4 +319,4 @@ jobs:
# never reaches it. Pin the target dir (matching the cache step above) so
# its own tests run off this compile rather than a second bundled build.
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,sqs_trigger --all -- --nocapture --test-threads=10
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,sqs_trigger --all -- --nocapture --test-threads=10
+4 -1
View File
@@ -40,11 +40,14 @@ jobs:
# an author_association gate: the pull_request webhook payload reports private org
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
# skips auto-review for every private member.
# Dependabot-triggered runs get a separate secret scope, so this job has no API
# key and cannot review. workflow_call stays open for an explicit request.
if: |
github.event_name == 'workflow_call' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.fork == false
github.event.pull_request.head.repo.fork == false &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
permissions:
contents: read
+4 -2
View File
@@ -13,7 +13,8 @@ on:
jobs:
notify_discord_when_pr_opened:
if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review')
# No thread is opened for Dependabot PRs, so nothing downstream may assume one.
if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') && (github.event.pull_request.user.login != 'dependabot[bot]')
uses: ./.github/workflows/shareable-discord-notification.yml
with:
PR_TITLE: ${{ github.event.pull_request.title }}
@@ -28,7 +29,8 @@ jobs:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
merge_success_emoji:
if: github.event.action == 'closed'
# Must match open_thread's exclusion: this reacts to a thread that was never created.
if: github.event.action == 'closed' && github.event.pull_request.user.login != 'dependabot[bot]'
uses: ./.github/workflows/shareable-discord-notification.yml
with:
PR_STATUS: "merged"
+35 -12
View File
@@ -40,6 +40,11 @@ jobs:
runs-on: ubicloud
if: (github.event_name != 'workflow_dispatch') || (github.event.inputs &&
!github.event.inputs.ee)
# Jobs that build from, extract or retag this image must reference it by
# this digest: concurrent main-branch runs push :dev too, so a tag run
# reading :dev can ship a main build under the release version.
outputs:
digest: ${{ steps.docker_build.outputs.digest }}
steps:
- uses: actions/checkout@v4
with:
@@ -112,6 +117,8 @@ jobs:
build_ee:
runs-on: ubicloud
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
outputs:
digest: ${{ steps.docker_build.outputs.digest }}
steps:
- uses: actions/checkout@v4
with:
@@ -192,31 +199,31 @@ jobs:
- uses: actions/checkout@v4
- run: |
# pulling docker image with desired arch so that actions-docker-extract doesn't do it
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
- run: |
# Checks the image is in docker prior to running actions-docker-extract. It fails if not
# Also useful to visually check that the arch is the right opencontainers
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
- uses: shrink/actions-docker-extract@v3
id: extract
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
path: "/usr/src/app/windmill"
- uses: shrink/actions-docker-extract@v3
id: extract-duckdb-ffi-internal
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so"
- uses: shrink/actions-docker-extract@v3
id: extract-ee
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
path: "/usr/src/app/windmill"
- name: Rename binary with corresponding architecture
@@ -373,8 +380,8 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag main and latest
run: |
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
- uses: sigstore/cosign-installer@v4.1.2
if: startsWith(github.ref, 'refs/tags/v')
@@ -409,8 +416,8 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag main and latest for ee
run: |
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main
- uses: sigstore/cosign-installer@v4.1.2
if: startsWith(github.ref, 'refs/tags/v')
@@ -447,12 +454,18 @@ jobs:
dockerhub-user: windmilllabs
dockerhub-password: ${{ secrets.DOCKER_PAT }}
# Filed under main, not under the release tag this job runs on. The scanned image is
# `-ee:main` and the Security tab only reads the default branch, so tag-only uploads
# left main's last Docker Scout result at 2025-01-29: the tool showed as failing and
# its alerts could never close. `sha` is the release commit, which is on main.
- name: Upload SARIF result
id: upload-sarif
if: ${{ github.event_name != 'pull_request_target' }}
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: sarif.output.json
ref: refs/heads/main
sha: ${{ github.sha }}
# docker_scout_ee:
# runs-on: ubicloud
@@ -549,6 +562,8 @@ jobs:
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileCuda"
build-args: |
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
@@ -602,6 +617,8 @@ jobs:
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileSlim"
build-args: |
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
@@ -656,6 +673,8 @@ jobs:
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileSlimEe"
build-args: |
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
@@ -709,6 +728,8 @@ jobs:
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileFull"
build-args: |
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
tags: |
${{ steps.meta-public.outputs.tags }}
labels: |
@@ -761,6 +782,8 @@ jobs:
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileFullEe"
build-args: |
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
+4 -1
View File
@@ -38,11 +38,14 @@ jobs:
# an author_association gate: the pull_request webhook payload reports private org
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
# skips auto-review for every private member.
# Dependabot-triggered runs get a separate secret scope, so this job has no API
# key and cannot review. workflow_call stays open for an explicit request.
if: |
github.event_name == 'workflow_call' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.fork == false
github.event.pull_request.head.repo.fork == false &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
permissions:
contents: read
+4 -1
View File
@@ -37,11 +37,14 @@ jobs:
# an author_association gate: the pull_request webhook payload reports private org
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
# skips auto-review for every private member.
# Dependabot-triggered runs get a separate secret scope, so this job has no API
# key and cannot review. workflow_call stays open for an explicit request.
if: |
github.event_name == 'workflow_call' ||
(
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) &&
github.event.pull_request.head.repo.fork == false
github.event.pull_request.head.repo.fork == false &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
permissions:
contents: read
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.815.0"
".": "1.817.0"
}
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22)
### Features
* add workspace mentions to AI composer ([#11264](https://github.com/windmill-labs/windmill/issues/11264)) ([e7ba595](https://github.com/windmill-labs/windmill/commit/e7ba5954b69514d72c140f9b1e697fb2236df23e))
* allow workspace-scoped dynamic custom tags ([#11283](https://github.com/windmill-labs/windmill/issues/11283)) ([efa7a0a](https://github.com/windmill-labs/windmill/commit/efa7a0a70af43f8827542059dd450326a9b88d62))
* check dynamic worker tags on the tag they resolve to ([#11271](https://github.com/windmill-labs/windmill/issues/11271)) ([69fafb9](https://github.com/windmill-labs/windmill/commit/69fafb9262f90184e6c2dc9a84f1c3feeb9d9638))
* deploy from the UI to a workspace on another instance ([#11245](https://github.com/windmill-labs/windmill/issues/11245)) ([4d12ea4](https://github.com/windmill-labs/windmill/commit/4d12ea461454f89367bd91daa7e8c39a89035507))
### Bug Fixes
* add missing credential fields to settings redaction lists ([#11272](https://github.com/windmill-labs/windmill/issues/11272)) ([2a40f25](https://github.com/windmill-labs/windmill/commit/2a40f25bda81d41b9f578338c85656744f59ebfc))
* apply SSRF validation to workspace webhook URLs ([#11285](https://github.com/windmill-labs/windmill/issues/11285)) ([586699c](https://github.com/windmill-labs/windmill/commit/586699c4938b5203920a43e69a706310078b93f5))
* apply token path scopes to the native trigger list ([#11281](https://github.com/windmill-labs/windmill/issues/11281)) ([faf7b22](https://github.com/windmill-labs/windmill/commit/faf7b22be0827738c90c1f3ed6cd3e214d6c63d3))
* check workflow-as-code task tags against CUSTOM_TAGS ([#11273](https://github.com/windmill-labs/windmill/issues/11273)) ([2699d50](https://github.com/windmill-labs/windmill/commit/2699d5065ea3dfe985636733fb6022ca23187291))
* clear the impersonation token from session storage on logout ([#11274](https://github.com/windmill-labs/windmill/issues/11274)) ([9936673](https://github.com/windmill-labs/windmill/commit/9936673ee62fb6dd929a654a6138aeca687c0fb7))
* enforce token path scopes on GET /raw_apps/list ([#11284](https://github.com/windmill-labs/windmill/issues/11284)) ([9ad2c91](https://github.com/windmill-labs/windmill/commit/9ad2c91ddb661ab60ada9ec4039ef8d025602969))
* keep a cancel that lands after a worker last read its queue row ([#11246](https://github.com/windmill-labs/windmill/issues/11246)) ([02006f3](https://github.com/windmill-labs/windmill/commit/02006f364a4217e967435d9e71d8617c03b65413))
* size the AI chat output budget by model, not provider ([#11286](https://github.com/windmill-labs/windmill/issues/11286)) ([7d0f2ce](https://github.com/windmill-labs/windmill/commit/7d0f2ce3bf920a68f1ea22d59481d239947f1b51))
* stop cutting DeepSeek chat turns off mid-thought ([#11282](https://github.com/windmill-labs/windmill/issues/11282)) ([5821700](https://github.com/windmill-labs/windmill/commit/58217006ece56bbaf0447af67cac487e72b928c7))
* stop the raw app editor stealing focus in ai sessions ([#11287](https://github.com/windmill-labs/windmill/issues/11287)) ([7322143](https://github.com/windmill-labs/windmill/commit/732214361f36eb07d65854118bd053c96ce681fb))
## [1.816.0](https://github.com/windmill-labs/windmill/compare/v1.815.0...v1.816.0) (2026-09-21)
### Features
* add an ACL editor for data table roles ([#11063](https://github.com/windmill-labs/windmill/issues/11063)) ([9994e03](https://github.com/windmill-labs/windmill/commit/9994e03bc75404503001a5db37e175b66e7371f2))
* add per-model context window override in workspace AI settings ([#11232](https://github.com/windmill-labs/windmill/issues/11232)) ([c637b06](https://github.com/windmill-labs/windmill/commit/c637b06a548a5725aceb7504bcb62383d35614ac))
* **ai-chat:** disclose an app's exposure on deploy, refuse drag-and-drop apps ([#11137](https://github.com/windmill-labs/windmill/issues/11137)) ([85b36a2](https://github.com/windmill-labs/windmill/commit/85b36a2de5f3fa08750b6d2ab8203f4b745cfdd2))
* data table roles in the DB manager and raw apps ([#11139](https://github.com/windmill-labs/windmill/issues/11139)) ([e1e3692](https://github.com/windmill-labs/windmill/commit/e1e3692fbc82d50c021ef8bf8ca7019d760705f0))
* drafts follow their item through a move; behind means base ≠ head ([#10577](https://github.com/windmill-labs/windmill/issues/10577)) ([f00b2fc](https://github.com/windmill-labs/windmill/commit/f00b2fcb1e65cb8f8e95d55f7cc58b816ac7d89d))
* edit variables, resources and triggers in their own session tab ([#11206](https://github.com/windmill-labs/windmill/issues/11206)) ([6186a06](https://github.com/windmill-labs/windmill/commit/6186a0645d3f3e92e06b666a6b5774b8af2b488f))
* let every EE instance set the announcement banner ([#11238](https://github.com/windmill-labs/windmill/issues/11238)) ([87761d1](https://github.com/windmill-labs/windmill/commit/87761d179799026ebaf9ed91bf589852b5453ee3))
* snap-collapse the sidebar by dragging it to the left edge ([#11225](https://github.com/windmill-labs/windmill/issues/11225)) ([56a6ad3](https://github.com/windmill-labs/windmill/commit/56a6ad322e8354695a0a96eef9e83ffa135b3ad4))
### Bug Fixes
* bump git sync hub scripts to cli 1.815.1 ([#11239](https://github.com/windmill-labs/windmill/issues/11239)) ([9e0e835](https://github.com/windmill-labs/windmill/commit/9e0e835c6876305d3a44902fb1432fd9c2348a7c))
* check custom_instance_user can connect during instance database setup ([#11244](https://github.com/windmill-labs/windmill/issues/11244)) ([1b0f119](https://github.com/windmill-labs/windmill/commit/1b0f119c7b8ac2aacf2e2b4428c57f661df87ae5))
* **cli:** read wmill.yaml from the branch a git-sync deploy writes to ([#11236](https://github.com/windmill-labs/windmill/issues/11236)) ([974b026](https://github.com/windmill-labs/windmill/commit/974b0265f85f9cfac65bbd2f6ebbcb0e966e3301))
* guard data table migration routes against operators and unauthorized authors ([#11243](https://github.com/windmill-labs/windmill/issues/11243)) ([b742544](https://github.com/windmill-labs/windmill/commit/b7425443e1e08be029fc676047f727f84b646323))
* tell the user when a draft conflict has stopped their edits saving ([#11241](https://github.com/windmill-labs/windmill/issues/11241)) ([d8ebbc0](https://github.com/windmill-labs/windmill/commit/d8ebbc06f5c56dad2570115700d640105d856c57))
### Performance Improvements
* index audit logs by workspace and operation ([#11235](https://github.com/windmill-labs/windmill/issues/11235)) ([3b8c326](https://github.com/windmill-labs/windmill/commit/3b8c3264ecb33884420b1bdac9cae9c841e721a0))
* keep the sessions page fast with many ai sessions ([#11224](https://github.com/windmill-labs/windmill/issues/11224)) ([4cbc944](https://github.com/windmill-labs/windmill/commit/4cbc944c0bcb938300c964437d745d15a3ea608f))
* retire the legacy pre-partitioning audit table ([#11240](https://github.com/windmill-labs/windmill/issues/11240)) ([9f2685d](https://github.com/windmill-labs/windmill/commit/9f2685d788e624c9ed9db5cbaee456ea21af2b29))
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
@@ -98,9 +98,11 @@ export async function runEval<THelpers, TOutput>(
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
// `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the
// model was sent, and a tool reading `this.def` must see that one, as it does in production.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
fn: async function (this: unknown, p: any) {
toolCallsCount++;
toolsCalled.push(tool.def.function.name);
let argumentsText = "";
@@ -120,7 +122,7 @@ export async function runEval<THelpers, TOutput>(
toolName: tool.def.function.name,
argumentsText,
});
return tool.fn(p);
return tool.fn.call(this, p);
},
}));
+21 -11
View File
@@ -1112,9 +1112,9 @@
- opens the Kafka triggers page
- does not write, deploy, or delete anything
- id: global-openpage7-compare-review
- id: global-draft-does-not-open-compare
prompt: |-
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself.
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed.
runtime:
maxTurns: 8
validate:
@@ -1122,6 +1122,24 @@
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- open_page
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- creates the script draft without opening any workspace page
- does not deploy or delete anything
- id: global-openpage7-compare-fork-deploy
prompt: |-
I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
@@ -1131,17 +1149,9 @@
field: page
stringIncludesAnyOf:
- compare
# The eval chat is untracked (no modified-items mask), so the model must scope
# the review by passing the item it changed explicitly — an omitted mask would
# preselect every pending change in the workspace.
- tool: open_page
field: items
stringIncludesAnyOf:
- f/evals/global/compare_review_demo
skipJudge: true
judgeChecklist:
- creates the script draft, then opens the Compare & Deploy review page instead of deploying itself
- preselects only the created script on the review page
- opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent
- does not deploy or delete anything
- id: global-openpage8-runs-label-and-worker
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at, member_since, target_changed_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::timestamptz IS NOT NULL\n OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,\n token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,\n proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,\n member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at\n WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at\n RETURNING connected_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "connected_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Text",
"Varchar",
"Varchar",
"Timestamptz",
"Timestamptz",
"Timestamptz",
"Text"
]
},
"nullable": [
false
]
},
"hash": "132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "remote_deploy_target",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scheduled_for",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE remote_deploy_token SET token = $1\n WHERE workspace_id = $2 AND email = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(DISTINCT email) FROM usr WHERE is_service_account AND NOT disabled",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, token FROM remote_deploy_token\n WHERE workspace_id = $1 AND token <> '' FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
"describe": {
"columns": [
{
@@ -236,5 +236,5 @@
true
]
},
"hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e"
"hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT clock_timestamp() AS \"started_at!\", s.remote_deploy_target,\n s.remote_deploy_target_changed_at,\n (SELECT u.created_at FROM usr u\n WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since\n FROM workspace_settings s WHERE s.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "started_at!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "remote_deploy_target",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "remote_deploy_target_changed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "member_since",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null,
true,
true,
null
]
},
"hash": "51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)\n VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',\n 'test@windmill.dev', 'read only', false, true)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'\n WHERE workspace_id = 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429"
}
@@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t\n JOIN workspace_settings s ON s.workspace_id = t.workspace_id\n WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3\n AND t.remote_workspace_id = $4 AND t.token <> ''\n AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at\n AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id\n AND u.email = t.email AND u.created_at = t.member_since)\n OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "remote_email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "proxy_key",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "connected_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at)\n VALUES ($1, $2, '', '', '', '', '', clock_timestamp())\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n token = '', proxy_key = '', connected_at = clock_timestamp()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1\n AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, canceled_reason = $3 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,\n remote_deploy_target_changed_at = CASE\n WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()\n ELSE remote_deploy_target_changed_at END\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE remote_deploy_token SET token = 'not-under-this-key'\n WHERE workspace_id = 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',\n 'test@windmill.dev', 'gone', true)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n ))\n RETURNING token_prefix",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n AND NOT starts_with(label, 'remote-deploy:')\n ))\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99"
"hash": "b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "schedule",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "cron_version",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "timezone",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
false,
true,
false
]
},
"hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()\n WHERE workspace_id = 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2",
"query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
"hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf"
"hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET created_at = created_at - interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "canceled!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "canceled_reason",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
true
]
},
"hash": "ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ",
"describe": {
"columns": [
{
@@ -93,7 +93,10 @@
"Int8",
"Int8",
"Text",
"Bool"
"Bool",
"Bool",
"TextArray",
"TextArray"
]
},
"nullable": [
@@ -111,5 +114,5 @@
false
]
},
"hash": "a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a"
"hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true",
"query": "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [
{
@@ -16,5 +16,5 @@
null
]
},
"hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf"
"hash": "f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76"
}
+172 -155
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.815.0"
version = "1.817.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.815.0"
version = "1.817.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -493,7 +493,7 @@ sql-builder = "^3"
# behind a feature and changed `hash_password`), so a float breaks the build.
argon2 = "0.6"
quick_cache = "^0"
rand = "=0.9.0"
rand = "^0.9.3"
rand_core = { version = "^0", features = ["std"] }
arc-swap = "1"
ed25519-dalek = { version = "2", features = ["rand_core"] }
@@ -514,7 +514,7 @@ reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart"
eventsource-stream = "0.2.3"
time = "^0"
serde_urlencoded = "^0"
astral-tokio-tar = "^0.5.6"
astral-tokio-tar = "^0.6.2"
tempfile = "^3"
x509-parser = "^0.16"
tokio-util = { version = "=0.7.17", features = ["io"] }
@@ -523,7 +523,7 @@ itertools = "^0.14.0"
regex = "^1"
semver = "^1"
aws-sigv4 = "^1.3.4"
aws-sdk-config = "=1.68.0"
aws-sdk-config = "^1.94.0"
aws-sdk-rds = "^1"
async-trait = "0.1.88"
@@ -685,8 +685,8 @@ aws-sdk-bedrockruntime = "=1.122.0"
aws-credential-types = "^1"
aws-smithy-types = "^1"
aws-sdk-secretsmanager = "^1"
aws-sdk-sqs = "=1.77.0"
aws-sdk-sts = "=1.79.0"
aws-sdk-sqs = "^1.89.0"
aws-sdk-sts = "^1.91.0"
aws-sdk-sso = "=1.77.0"
aws-sdk-ssooidc = "=1.78.0"
rustls = "=0.23.35"
@@ -757,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl
hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls-native-certs = "0.8"
webpki-roots = "1"
rcgen = "0.13"
+1 -1
View File
@@ -97,7 +97,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
| id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence |
|---|---|---|---|---|---|---|---|---|---|
| T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b |
| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 |
| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; workspace webhook URLs are SSRF-checked when saved, not when sent, behind the `ALLOW_PRIVATE_WEBHOOK_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 |
| T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 |
| T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 |
| T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e |
+1 -1
View File
@@ -1 +1 @@
38699f705abb4e223d86e18b30d004aeaf589401
85683db6bfc78840da48fd9ca1bc22d8651162e4
@@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
r for r in body_schema['required'] if r in include_fields
]
# Apply opaque_fields: simplify listed properties to {"type": "object"}
# Apply opaque_fields: simplify listed properties to {"type": "object"},
# keeping the description, which is all a caller learns about the field
if body_schema and opaque_fields and 'properties' in body_schema:
for field in opaque_fields:
if field in body_schema['properties']:
body_schema['properties'][field] = {"type": "object"}
opaque = {"type": "object"}
description = body_schema['properties'][field].get('description')
if description:
opaque['description'] = description
body_schema['properties'][field] = opaque
# If we have required fields specified and a body schema, update the required array
if body_schema and required_fields:
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS remote_deploy_token;
ALTER TABLE workspace_settings
DROP COLUMN IF EXISTS remote_deploy_target,
DROP COLUMN IF EXISTS remote_deploy_target_changed_at;
@@ -0,0 +1,35 @@
-- The workspace on another Windmill instance this workspace deploys into from the UI:
-- `{"base_url": ..., "workspace_id": ...}`, and when it last changed. A token connected under an
-- earlier change counts for nothing, even once the setting points back at the target it was for.
ALTER TABLE workspace_settings
ADD COLUMN remote_deploy_target JSONB,
ADD COLUMN remote_deploy_target_changed_at TIMESTAMPTZ;
-- One user's token for the remote deploy target, encrypted with the workspace key.
-- `base_url`/`remote_workspace_id` name the target it was granted for: a token is only
-- ever sent to that target, so re-pointing the workspace setting cannot redirect it.
-- Keyed by the account rather than by membership, since a superadmin deploys from workspaces
-- it is not a member of. The account key cascades: whatever deletes or renames an account
-- takes its tokens along, so a later account with the same address cannot inherit them.
CREATE TABLE remote_deploy_token (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
base_url VARCHAR(1000) NOT NULL,
remote_workspace_id VARCHAR(50) NOT NULL,
token TEXT NOT NULL,
remote_email VARCHAR(255) NOT NULL,
-- Part of every proxy URL, and readable only by its owner through the API: a link from
-- elsewhere, which rides the session cookie, cannot know it and so cannot spend the token.
proxy_key VARCHAR(64) NOT NULL,
-- When the connect that wrote the row started, or when a disconnect emptied it.
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- What the connect ran under: the `created_at` of the owner's membership (NULL for a
-- superadmin with none) and the target's `remote_deploy_target_changed_at`. The row counts
-- only while both are still the same, so a re-add or a target change voids it.
member_since TIMESTAMPTZ,
target_changed_at TIMESTAMPTZ,
PRIMARY KEY (workspace_id, email)
);
GRANT ALL ON remote_deploy_token TO windmill_user;
GRANT ALL ON remote_deploy_token TO windmill_admin;
@@ -0,0 +1,6 @@
-- `created_by` tells this migration's row from one a user created by hand; a hub
-- sync updates in place and leaves it alone.
DELETE FROM resource_type
WHERE workspace_id = 'admins'
AND name = 'ai_instruction'
AND created_by = 'system';
@@ -0,0 +1,15 @@
-- Folder instructions for the AI chat: a markdown file resource whose
-- `value.content` applies to every item under the directory it lives in.
-- `format_extension` makes the resource editor render it as a plain .md file.
-- Seeded under 'admins' so every workspace sees it before the next hub sync.
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES (
'admins',
'ai_instruction',
'{"type": "object", "properties": {"content": {"type": "string"}}}',
'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.',
'system',
'md',
now()
)
ON CONFLICT (workspace_id, name) DO NOTHING;
+81 -10
View File
@@ -16,11 +16,13 @@ use windmill_parser::{
use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned};
use swc_ecma_ast::{
ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit,
MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt,
Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit,
TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl,
TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType,
ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident,
IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number,
ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType,
TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType,
TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn,
TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr,
UnaryOp,
};
use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax};
@@ -578,6 +580,15 @@ fn parse_param(
}
};
let literal = match &*right {
Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right),
_ => None,
};
// Types an untyped object default that is not a plain literal (`{ a: x }`).
let literal_shape = match &*right {
Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))),
_ => None,
};
let dflt = if skip_dflt {
None
} else {
@@ -593,14 +604,19 @@ fn parse_param(
Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)),
Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)),
Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)),
Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm),
Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm),
Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => {
literal.or_else(|| eval_span(span, cm))
}
_ => None,
}
};
if typ == Typ::Unknown && dflt.is_some() {
typ = json_to_typ(dflt.as_ref().unwrap(), false);
if typ == Typ::Unknown {
match &dflt {
Some(d) => typ = json_to_typ(d, false),
None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown),
None => {}
}
}
Ok(Arg {
otyp,
@@ -636,6 +652,59 @@ fn parse_param(
r
}
/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are
/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`).
fn literal_json(expr: &Expr) -> Option<Value> {
let num = |v: f64| {
if v.fract() == 0.0 && v.abs() < 9007199254740992.0 {
Some(serde_json::json!(v as i64))
} else {
serde_json::Number::from_f64(v).map(Value::Number)
}
};
match expr {
Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
Expr::Lit(Lit::Num(n)) => num(n.value),
Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)),
Expr::Lit(Lit::Null(_)) => Some(Value::Null),
Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg {
Expr::Lit(Lit::Num(n)) => num(-n.value),
_ => None,
},
Expr::Paren(p) => literal_json(&p.expr),
Expr::Array(a) => a
.elems
.iter()
.map(|e| match e {
Some(ExprOrSpread { spread: None, expr }) => literal_json(expr),
_ => None,
})
.collect::<Option<Vec<_>>>()
.map(Value::Array),
Expr::Object(o) => o
.props
.iter()
.map(|p| match p {
PropOrSpread::Prop(p) => match &**p {
Prop::KeyValue(KeyValueProp { key, value }) => {
let key = match key {
PropName::Ident(i) => i.sym.to_string(),
PropName::Str(s) => s.value.to_string(),
PropName::Num(n) => n.value.to_string(),
_ => return None,
};
Some((key, literal_json(value)?))
}
_ => None,
},
_ => None,
})
.collect::<Option<serde_json::Map<_, _>>>()
.map(Value::Object),
_ => None,
}
}
fn eval_span(span: Span, cm: &Lrc<SourceMap>) -> Option<Value> {
let expr = cm
.span_to_snippet(span)
@@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result<serde_json::Value, String> {
#[cfg(not(target_arch = "wasm32"))]
pub fn eval_sync(_code: &str) -> Result<serde_json::Value, String> {
panic!("eval_sync is only available in wasm32")
// The server parses signatures with defaults too: a default that is not JSON
// (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse.
Err("evaluating a JavaScript default needs the wasm build".to_string())
}
@@ -7,6 +7,38 @@ mod tests {
remove_pinned_import_specifiers,
};
/// The server parses defaults natively, where a non-JSON literal cannot be evaluated
/// but must still type and default the argument as the editor's wasm parse does.
#[test]
fn non_json_literal_defaults_parse_natively() {
let code =
"export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}";
let sig = parse_deno_signature(code, false, false, None).unwrap();
let defaults: Vec<_> = sig
.args
.iter()
.map(|a| (a.typ.clone(), a.default.clone()))
.collect();
assert_eq!(
defaults,
vec![
(
Typ::Object(ObjectType::new(
None,
Some(vec![
ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)),
ObjectProperty::new("n".into(), Box::new(Typ::Int)),
])
)),
Some(json!({"enabled": true, "n": -1}))
),
(Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))),
// Not a plain literal: typed by its shape, with no default value.
(Typ::Object(ObjectType::new(None, None)), None),
]
);
}
#[test]
fn test_imports_basic() {
let code = r#"
+113 -68
View File
@@ -775,9 +775,9 @@ dependencies = [
[[package]]
name = "cmov"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
@@ -1530,7 +1530,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
@@ -2580,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@@ -2816,7 +2816,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"rand 0.8.6",
"smallvec",
"zeroize",
]
@@ -2902,15 +2902,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.78"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -2934,9 +2933,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.114"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
@@ -3047,6 +3046,16 @@ dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared 0.13.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
@@ -3064,7 +3073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.5",
"rand 0.8.6",
]
[[package]]
@@ -3098,6 +3107,15 @@ dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "php-parser-rs"
version = "0.1.3"
@@ -3169,9 +3187,9 @@ dependencies = [
[[package]]
name = "postgres-protocol"
version = "0.6.11"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc"
checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
dependencies = [
"base64 0.22.1",
"byteorder",
@@ -3187,9 +3205,9 @@ dependencies = [
[[package]]
name = "postgres-types"
version = "0.2.9"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48"
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
dependencies = [
"array-init",
"bit-vec 0.6.3",
@@ -3197,7 +3215,7 @@ dependencies = [
"chrono",
"fallible-iterator",
"postgres-protocol",
"serde",
"serde_core",
"serde_json",
"uuid",
]
@@ -3394,15 +3412,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.0",
"rand 0.9.5",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -3457,9 +3475,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -3468,13 +3486,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.0"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"zerocopy",
]
[[package]]
@@ -3752,7 +3769,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503c78f59814e2664c9980b739b19e40f549233ecf39928040ee54fdd431f614"
dependencies = [
"rand 0.8.5",
"rand 0.8.6",
]
[[package]]
@@ -3828,7 +3845,7 @@ dependencies = [
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rand 0.8.6",
"rkyv",
"serde",
"serde_json",
@@ -3944,9 +3961,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4574,7 +4591,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.5",
"rand 0.8.6",
"rsa",
"serde",
"sha1",
@@ -4585,7 +4602,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"uuid",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -4616,7 +4633,7 @@ dependencies = [
"memchr",
"num-bigint",
"once_cell",
"rand 0.8.5",
"rand 0.8.6",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -4626,7 +4643,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"uuid",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -5043,9 +5060,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -5254,9 +5271,9 @@ dependencies = [
[[package]]
name = "tokio-postgres"
version = "0.7.13"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0"
checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
dependencies = [
"async-trait",
"byteorder",
@@ -5267,15 +5284,15 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
"phf 0.11.3",
"phf 0.13.1",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand 0.9.0",
"socket2 0.5.10",
"rand 0.10.1",
"socket2 0.6.3",
"tokio",
"tokio-util",
"whoami",
"whoami 2.1.0",
]
[[package]]
@@ -5777,7 +5794,7 @@ dependencies = [
"getopts",
"log",
"phf_codegen",
"rand 0.8.5",
"rand 0.8.6",
]
[[package]]
@@ -5899,6 +5916,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@@ -5923,6 +5949,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.103"
@@ -6134,7 +6169,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"wasite 0.1.0",
]
[[package]]
name = "whoami"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361"
dependencies = [
"libredox",
"wasite 1.0.2",
"web-sys",
]
@@ -6171,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6216,7 +6261,7 @@ dependencies = [
"pkcs1",
"postgres-native-tls",
"quick_cache",
"rand 0.9.0",
"rand 0.9.5",
"regex",
"reqwest",
"reqwest-middleware",
@@ -6257,7 +6302,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6269,7 +6314,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"convert_case",
"serde",
@@ -6278,7 +6323,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6290,7 +6335,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6302,7 +6347,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6314,7 +6359,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6326,7 +6371,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6338,7 +6383,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6349,7 +6394,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6360,7 +6405,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6372,7 +6417,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6383,7 +6428,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6405,7 +6450,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6417,7 +6462,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6431,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6448,7 +6493,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6461,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"serde",
@@ -6473,7 +6518,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6491,7 +6536,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6507,7 +6552,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6523,7 +6568,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6555,7 +6600,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6569,14 +6614,14 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.815.0"
version = "1.817.0"
dependencies = [
"anyhow",
"bitflags",
"chrono",
"hex",
"itertools 0.14.0",
"rand 0.9.0",
"rand 0.9.5",
"serde",
"serde_json",
"sqlx",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.815.0"
version = "1.817.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -0,0 +1,402 @@
use serde_json::{json, Map, Value};
use crate::{MainArgSignature, ObjectProperty, Typ};
/// The run-form schema of a script's `main`, derived the way the editor derives it
/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`),
/// so a script deployed without a schema gets the one the editor would have sent.
/// `previous` is the schema of the version being superseded: as in the editor, it is
/// updated in place, so what the user set on it (argument order, descriptions, ...)
/// survives wherever the code still agrees with it.
pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value {
let mut schema = match previous {
Some(Value::Object(p)) => p.clone(),
_ => json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
})
.as_object()
.cloned()
.unwrap_or_default(),
};
let mut old_props = match schema.remove("properties") {
Some(Value::Object(p)) => p,
_ => Map::new(),
};
let mut properties = Map::new();
let mut required = vec![];
for arg in &sig.args {
let mut prop = match old_props.remove(&arg.name) {
Some(Value::Object(p)) => p,
_ => fresh_property(),
};
if !matches!(arg.typ, Typ::OneOf(_)) {
prop.remove("oneOf");
}
apply_typ(&arg.typ, &mut prop);
// `T | T[]` marks an argument debouncing accumulates into.
if let Some(otyp) = arg
.otyp
.as_deref()
.filter(|o| o.contains('[') && o.contains('|'))
{
prop.insert("originalType".into(), json!(otyp));
}
match &arg.default {
Some(d) => prop.insert("default".into(), d.clone()),
None => prop.remove("default"),
};
if !arg.has_default {
required.push(json!(arg.name));
}
properties.insert(arg.name.clone(), Value::Object(prop));
}
schema.insert("properties".into(), Value::Object(properties));
schema.insert("required".into(), Value::Array(required));
if sig.has_cmd_binding == Some(true) {
schema.insert("x-windmill-ps-cmd-binding".into(), json!(true));
schema.insert(
"x-windmill-ps-supports-should-process".into(),
json!(sig.supports_should_process.unwrap_or(false)),
);
} else {
schema.remove("x-windmill-ps-cmd-binding");
schema.remove("x-windmill-ps-supports-should-process");
}
Value::Object(schema)
}
/// Keys a user sets on a property in the schema editor, kept whatever the code says.
const USER_FIELDS: &[&str] = &[
"description",
"pattern",
"min",
"max",
"currency",
"currencyLocale",
"multiselect",
"customErrorMessage",
"required",
"showExpr",
"password",
"order",
"dateFormat",
"title",
"placeholder",
];
/// Item keys a list the parser cannot see into keeps from the previous schema.
const ITEMS_USER_FIELDS: &[&str] = &[
"properties",
"required",
"additionalProperties",
"enum",
"resourceType",
"contentEncoding",
"description",
];
fn fresh_property() -> Map<String, Value> {
let mut p = Map::new();
p.insert("description".into(), json!(""));
p.insert("type".into(), json!(""));
p
}
fn object_properties(
props: &[ObjectProperty],
old: Option<&Map<String, Value>>,
) -> Map<String, Value> {
props
.iter()
.map(|p| {
let mut prop = old
.and_then(|o| o.get(&p.key))
.and_then(Value::as_object)
.cloned()
.unwrap_or_else(fresh_property);
apply_typ(&p.typ, &mut prop);
(p.key.clone(), Value::Object(prop))
})
.collect()
}
fn kept_items(old: &Map<String, Value>) -> Value {
let old_items = old.get("items").and_then(Value::as_object);
let mut items = Map::new();
items.insert(
"type".into(),
old_items
.and_then(|i| i.get("type"))
.filter(|t| t.as_str().is_some_and(|t| !t.is_empty()))
.cloned()
.unwrap_or(json!("object")),
);
if let Some(old_items) = old_items {
for f in ITEMS_USER_FIELDS {
if let Some(v) = old_items.get(*f) {
items.insert(f.to_string(), v.clone());
}
}
}
Value::Object(items)
}
/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`.
fn apply_typ(typ: &Typ, old: &mut Map<String, Value>) {
let mut new = Map::new();
// Keys the editor sets to `undefined`, which drops them from the stored JSON.
let mut unset: Vec<&str> = vec![];
let mut set = |k: &str, v: Value| {
new.insert(k.to_string(), v);
};
match typ {
Typ::Int => set("type", json!("integer")),
Typ::Float => set("type", json!("number")),
Typ::Bool => set("type", json!("boolean")),
Typ::Email => {
set("type", json!("string"));
set("format", json!("email"));
}
Typ::Sql => {
set("type", json!("string"));
set("format", json!("sql"));
}
Typ::Bytes => {
set("type", json!("string"));
set("contentEncoding", json!("base64"));
set("originalType", json!("bytes"));
}
Typ::Datetime => {
set("type", json!("string"));
set("format", json!("date-time"));
}
Typ::Date => {
set("type", json!("string"));
set("format", json!("date"));
}
Typ::OneOf(variants) => {
set("type", json!("object"));
let old_variants = old.get("oneOf").and_then(Value::as_array);
let one_of: Vec<Value> = variants
.iter()
.map(|v| {
let old_variant = old_variants
.and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label))))
.and_then(Value::as_object);
let old_props = old_variant
.and_then(|o| o.get("properties"))
.and_then(Value::as_object);
let mut variant = json!({
"type": "object",
"title": v.label,
"properties": object_properties(&v.properties, old_props),
});
if let Some(order) = old_variant.and_then(|o| o.get("order")) {
variant["order"] = order.clone();
}
variant
})
.collect();
set("oneOf", Value::Array(one_of));
}
Typ::Object(o) => {
set("type", json!("object"));
if let Some(name) = &o.name {
set("format", json!(format!("resource-{name}")));
}
if let Some(props) = &o.props {
let old_props = old.get("properties").and_then(Value::as_object);
set(
"properties",
Value::Object(object_properties(props, old_props)),
);
}
}
Typ::Str(variants) => {
set("type", json!("string"));
match variants {
Some(v) => {
set("originalType", json!("enum"));
set("enum", json!(v));
}
None => {
set("originalType", json!("string"));
match old.get("enum") {
Some(e) if old.get("originalType") == Some(&json!("string")) => {
set("enum", e.clone())
}
_ => unset.push("enum"),
}
}
}
}
Typ::Resource(name) => {
set("type", json!("object"));
set("format", json!(format!("resource-{name}")));
}
Typ::DynSelect(name) => {
set("type", json!("object"));
set("format", json!(format!("dynselect-{name}")));
}
Typ::DynMultiselect(name) => {
set("type", json!("object"));
set("format", json!(format!("dynmultiselect-{name}")));
}
Typ::List(inner) => {
set("type", json!("array"));
let (items, original_type) = match inner.as_ref() {
Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"),
Typ::Bytes => (
json!({"type": "string", "contentEncoding": "base64"}),
"bytes[]",
),
Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"),
Typ::Str(None) => {
let mut items = json!({"type": "string"});
if let Some(e) = old.get("items").and_then(|i| i.get("enum")) {
items["enum"] = e.clone();
}
(items, "string[]")
}
Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"),
Typ::Object(o) => {
if let Some(name) = &o.name {
set("format", json!(format!("resource-{name}")));
}
let items = match o.props.as_deref().filter(|p| !p.is_empty()) {
Some(props) => json!({
"type": "object",
"properties": object_properties(props, None),
}),
None => kept_items(old),
};
(items, "record[]")
}
_ => (kept_items(old), "object[]"),
};
set("items", items);
set("originalType", json!(original_type));
}
// The parser cannot tell: whatever type the user gave the argument stands.
Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))),
}
for f in USER_FIELDS {
if let Some(v) = old.get(*f) {
new.insert(f.to_string(), v.clone());
}
}
let old_type = old.get("type").cloned();
let new_type = new.get("type").cloned();
let old_format = old
.get("format")
.and_then(Value::as_str)
.map(str::to_string);
let new_format = new
.get("format")
.and_then(Value::as_str)
.map(str::to_string);
let is_date = |f: &Option<String>| matches!(f.as_deref(), Some("date" | "date-time"));
let mut keep_old_format = false;
// A changed type takes every key `new` sets, so none of the adjustments below
// apply: running them would carry the old type's date format or items over.
if old_type != new_type {
} else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") {
new.insert("format".into(), json!("date"));
} else if is_date(&new_format) {
keep_old_format = true;
} else if old.get("items").and_then(|i| i.get("type"))
!= new.get("items").and_then(|i| i.get("type"))
{
old.remove("items");
} else if old_type == Some(json!("string")) && old_format.is_some() {
keep_old_format = true;
}
if keep_old_format {
match old.get("format") {
Some(f) => {
new.insert("format".into(), f.clone());
}
None => {
new.remove("format");
unset.push("format");
}
}
}
if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") {
old.remove("format");
}
for k in unset {
if !new.contains_key(k) {
old.remove(k);
}
}
old.extend(new);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Arg, ObjectType};
fn arg(name: &str, typ: Typ, default: Option<Value>) -> Arg {
Arg {
name: name.to_string(),
has_default: default.is_some(),
default,
typ,
..Default::default()
}
}
#[test]
fn keeps_what_the_user_set_on_the_previous_version() {
let sig = MainArgSignature {
args: vec![
arg("n", Typ::Int, None),
arg("db", Typ::Resource("postgresql".into()), None),
arg(
"tags",
Typ::List(Box::new(Typ::Str(None))),
Some(json!(["a"])),
),
arg(
"opts",
Typ::Object(ObjectType::new(None, None)),
Some(json!({})),
),
arg("any", Typ::Unknown, None),
],
..Default::default()
};
let previous = json!({
"type": "object",
"order": ["db", "n"],
"properties": {
"n": {"type": "integer", "description": "how many", "min": 1, "nullable": true},
"tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}},
"gone": {"type": "string"},
},
});
let schema = main_arg_schema(&sig, Some(&previous));
assert_eq!(schema["order"], json!(["db", "n"]));
assert_eq!(schema["required"], json!(["n", "db", "any"]));
assert_eq!(
schema["properties"]["n"],
json!({"type": "integer", "description": "how many", "min": 1, "nullable": true})
);
assert!(schema["properties"].get("gone").is_none());
assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql");
assert_eq!(
schema["properties"]["tags"]["items"],
json!({"type": "string", "enum": ["a", "b"]})
);
assert_eq!(schema["properties"]["tags"]["default"], json!(["a"]));
assert_eq!(schema["properties"]["opts"]["type"], "object");
// Untyped in the code and new: any JSON value, as the editor stores it.
assert_eq!(schema["properties"]["any"]["type"], "");
}
}
@@ -15,6 +15,7 @@ use serde_json::Value;
pub mod asset_parser;
pub mod duckdb_builtins;
pub mod duckdb_macros;
pub mod json_schema;
pub mod sql_materialize;
/// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types)
+5 -5
View File
@@ -1944,13 +1944,13 @@ async fn process_notify_event(
#[cfg(feature = "http_trigger")]
"notify_http_trigger_change" => {
tracing::info!("HTTP trigger change detected: {}", payload);
match windmill_api::triggers::http::refresh_routers(db, true).await {
Ok(_) => {
match windmill_api::triggers::http::refresh_loaded_routers(db, true).await {
Ok(true) => {
tracing::info!("Refreshed HTTP routers (trigger change)");
}
Ok(false) => {}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
windmill_api::triggers::http::invalidate_routers();
return false;
}
};
@@ -2207,8 +2207,8 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
}
#[cfg(feature = "http_trigger")]
match windmill_api::triggers::http::refresh_routers(db, false).await {
Ok((true, _)) => {
match windmill_api::triggers::http::refresh_loaded_routers(db, false).await {
Ok(true) => {
tracing::info!(
"Refreshed HTTP routers (http workspaced route setting change)"
);
+4 -1
View File
@@ -171,6 +171,9 @@ postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(cha
FK: (workspace_id) -> workspace(id)
raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb), labels(text[])
FK: (workspace_id) -> workspace(id)
remote_deploy_token: workspace_id(char), email(char), base_url(char), remote_workspace_id(char), token(text), remote_email(char), proxy_key(char), connected_at(timestamptz), member_since(timestamptz), target_changed_at(timestamptz)
FK: (email) -> password(email)
FK: (workspace_id) -> workspace(id)
resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[])
FK: (workspace_id) -> workspace(id)
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool), display_name(char)
@@ -234,7 +237,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
+12 -15
View File
@@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
.send()
.await?;
assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests");
let resp = authed(
client().post(format!("{ws}/apps/update/{APP_PATH}")),
ADMIN_TOKEN,
)
.json(&json!({ "path": "u/test-user/My App" }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a space is literal: {}",
resp.text().await?
);
// Set on update: an app that already sits on such a path cannot be switched.
// Set on update: an app that already sits on such a path cannot be switched. Path
// validation now refuses these paths on every write, so plant the kind of app saved
// before it did.
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": "u/test-user/x:y",
"path": "u/test-user/legacy",
"summary": "App",
"value": {},
"policy": { "execution_mode": "publisher", "triggerables_v2": {} }
@@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
sqlx::query(
"UPDATE app SET path = 'u/test-user/x:y'
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'",
)
.execute(&db)
.await?;
let resp = authed(
client().post(format!("{ws}/apps/update/u/test-user/x:y")),
ADMIN_TOKEN,
@@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
.send()
.await?;
assert_eq!(resp.status(), 400, "switched to Guests on a `:` path");
let body = resp.text().await?;
assert!(body.contains("cannot be set to Guests"), "{body}");
Ok(())
}
+56
View File
@@ -1303,6 +1303,62 @@ async fn test_bun_wac_inline_task_cache_is_per_task(db: Pool<Postgres>) -> anyho
Ok(())
}
/// A task that picks its own worker tag is held to CUSTOM_TAGS like a flow step: with none
/// set, only a superadmin may send it to `gpu`, so the workflow fails instead of queueing it.
#[sqlx::test(fixtures("base"))]
async fn test_bun_wac_task_tag_outside_custom_tags(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
import { workflow, task } from "windmill-client";
const onGpu = task(async (n: number) => {
return n * 2;
}, { tag: "gpu" });
export const main = workflow(async (n: number) => {
return await onGpu(n);
});
"#
.to_owned();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
}))
.arg("n", serde_json::json!(5))
.as_user("test-user-2", "test2@windmill.dev")
.run_until_complete(&db, false, port)
.await;
assert!(!job.success, "the workflow must fail");
let result = job.json_result().unwrap();
let message = result["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("task 'onGpu' cannot run on tag 'gpu'"),
"unexpected error: {result}"
);
let children: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE parent_job = $1")
.bind(job.id)
.fetch_one(&db)
.await?;
assert_eq!(children, 0, "no child is queued on the refused tag");
Ok(())
}
// ============================================================================
// Environment Variable Tests
// ============================================================================
+125
View File
@@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool<Postgres>) -> a
Ok(())
}
// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env,
// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root
// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the
// root's value must reach the leaf; with no env anywhere every loop iteration is marked.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_flow_env_marks_sub_flows_only_without_ancestor_env(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Deployed, so their loops run as `FlowNode` sub-flows.
let deploy = |path: &'static str, version: i64, value: serde_json::Value| {
let db = db.clone();
async move {
sqlx::query(
"INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')",
)
.bind(path)
.bind(version)
.bind(&value)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')",
)
.bind(version)
.bind(path)
.bind(&value)
.execute(&db)
.await?;
RunJob::from(JobPayload::FlowDependencies {
path: path.to_string(),
dedicated_worker: None,
version,
debouncing_settings: Default::default(),
})
.run_until_complete(&db, false, port)
.await;
anyhow::Ok(())
}
};
deploy(
"f/system/imported_loop_no_env",
9991004,
json!({ "modules": [{ "id": "inner", "value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "[1, 2]" },
"skip_failures": false,
"parallel": true,
"modules": [{ "id": "leaf", "value": {
"type": "rawscript",
"language": "deno",
"content": "export function main(key: any) { return key ?? null; }",
"input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } }
}}, { "id": "tail", "value": {
// A single simple step is inlined rather than stored as a flow node.
"type": "rawscript",
"language": "deno",
"content": "export function main(prev: any) { return prev; }",
"input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } }
}}]
}}]}),
)
.await?;
let root = |flow_env: serde_json::Value| {
json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "[1]" },
"skip_failures": false,
"parallel": false,
"modules": [{ "id": "import", "value": {
"type": "flow",
"path": "f/system/imported_loop_no_env",
"input_transforms": {}
}}]
}}]})
};
deploy(
"f/system/root_with_env",
9991005,
root(json!({ "KEY": "from-root" })),
)
.await?;
deploy("f/system/root_no_env", 9991006, root(json!(null))).await?;
let run = |path: &'static str, version: i64| {
let db = db.clone();
async move {
let completed = RunJob::from(JobPayload::Flow {
path: path.to_string(),
dedicated_worker: None,
apply_preprocessor: false,
version,
labels: None,
})
.run_until_complete(&db, false, port)
.await;
// The mark of every loop iteration pushed under the run.
let marks = sqlx::query_as::<_, (String, Option<bool>)>(
"SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool
FROM v2_job j JOIN v2_job_completed c ON c.id = j.id
WHERE COALESCE(j.root_job, j.parent_job) = $1
AND j.flow_step_id IN ('outer', 'inner')",
)
.bind(completed.id)
.fetch_all(&db)
.await?;
anyhow::Ok((completed.json_result().unwrap(), marks))
}
};
let (result, marks) = run("f/system/root_with_env", 9991005).await?;
assert_eq!(result, json!([["from-root", "from-root"]]));
assert_eq!(marks, vec![("flownode".to_string(), None); 3]);
let (result, marks) = run("f/system/root_no_env", 9991006).await?;
assert_eq!(result, json!([[null, null]]));
assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]);
Ok(())
}
// stop_after_if predicate sees flow_env. Regression for the eval at line 614
// of `update_flow_status_after_job_completion_internal` which used to pass
// `None` for flow_env unconditionally.
+31
View File
@@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<P
.await
.expect("a valid origin list must sync");
}
/// The accent color is interpolated into a stylesheet every user loads, so the operator
/// path must refuse anything but `#rrggbb` just like the settings API does.
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_a_non_hex_accent_color(db: Pool<Postgres>) {
clear_settings_and_configs(&db).await;
let mut desired = BTreeMap::new();
desired.insert(
"accent_color".to_string(),
serde_json::json!("#000;}body{display:none"),
);
let err = windmill_common::instance_config::sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired,
)
.await
.expect_err("a non-hex accent color must fail the sync");
assert!(err.to_string().contains("accent_color"), "got: {err}");
assert!(get_global_setting(&db, "accent_color").await.is_none());
desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55"));
windmill_common::instance_config::sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired,
)
.await
.expect("a hex accent color must sync");
}
+1
View File
@@ -239,6 +239,7 @@ mod job_payload {
let result = RunJob::from(JobPayload::FlowNode {
id,
path: "f/system/hello_with_nodes_flow/forloop-0".into(),
no_inherited_flow_env: false,
})
.arg("iter", json!({ "value": "tests", "index": 0 }))
.run_until_complete(&db, false, port)
+90 -3
View File
@@ -516,10 +516,14 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
Some("SECRET_TOKEN_2"),
)
.await;
assert!(status.is_success(), "owner must mint a resume secret: {secret}");
assert!(
status.is_success(),
"owner must mint a resume secret: {secret}"
);
let secret = secret.trim().trim_matches('"').to_string();
let approval_result =
format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}");
let approval_result = format!(
"completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"
);
let (status, body) = get(&base, &approval_result, None).await;
assert!(
status.is_success(),
@@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
!status.is_success(),
"a public token must not let an anonymous caller cancel the run (got {status}): {body}"
);
// ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a
// future start. ----
let run_now = format!("queue/run_now/{RUNNING_JOB}");
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must not start another user's job early (got {status}): {body}"
);
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"a running job has no future start to move (got {status}): {body}"
);
sqlx::query(
"UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert!(
status.is_success(),
"owner must start their deferred job now (got {status}): {body}"
);
let due: bool =
sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid")
.bind(RUNNING_JOB)
.fetch_one(&db)
.await?;
assert!(due, "run_now must make the job due immediately");
// A schedule tick that is not due yet is refused: the schedule would queue that same
// tick again on completion and run twice.
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as)
VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *',
'u/test-user-2/running_secret', 'u/test-user-2')",
)
.execute(&db)
.await?;
sqlx::query(
"UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
sqlx::query(
"UPDATE v2_job_queue SET scheduled_for =
(date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"an upcoming schedule tick must not be started early (got {status}): {body}"
);
// A tick a concurrency limit pushed past its time is off the cron occurrences and
// may still be started early.
sqlx::query(
"UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert!(
status.is_success(),
"a deferred schedule tick must still start now (got {status}): {body}"
);
sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid")
.bind(RUNNING_JOB)
.execute(&db)
.await?;
// The owner still cancels their own job (no over-blocking). Keep this last: it
// takes RUNNING_JOB out of the queue.
let (status, body) = post(
+183
View File
@@ -0,0 +1,183 @@
//! A cancel reaches the queue row first and the worker only later: one landing after the worker's
//! last read of that row completes the run carrying no `canceled_by` of its own, and a perpetual
//! script must still stop rather than restart as if nothing had been cancelled.
use serde_json::json;
use sqlx::{types::Json, Pool, Postgres};
use uuid::Uuid;
use windmill_queue::{add_completed_job, get_mini_completed_job};
const W_ID: &str = "test-workspace";
const PATH: &str = "u/test-user/loop";
async fn start_perpetual_run(db: &Pool<Postgres>) -> anyhow::Result<Uuid> {
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, \
language, lock, restart_unless_cancelled) \
VALUES ($1, 1, $2, '', '', 'echo', 'test-user', 'bash', '', true)",
)
.bind(W_ID)
.bind(PATH)
.execute(db)
.await?;
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \
permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, args, \
visible_to_owner) \
VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', 'script', 1, \
$3, 'bash', 'bash', '{\"n\": 1}', true)",
)
.bind(id)
.bind(W_ID)
.bind(PATH)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \
VALUES ($1, $2, now(), true, now(), 'bash')",
)
.bind(id)
.bind(W_ID)
.execute(db)
.await?;
Ok(id)
}
/// Waits for another connection to be stuck on the queue row of `id`, which is where the
/// completion's delete lands while the cancel is uncommitted.
async fn wait_until_blocked_on(db: &Pool<Postgres>, id: Uuid) -> anyhow::Result<()> {
for _ in 0..100 {
let blocked: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_locks l JOIN v2_job_queue q \
ON q.ctid = ('(' || l.page || ',' || l.tuple || ')')::tid \
WHERE NOT l.granted AND l.locktype = 'tuple' AND q.id = $1) \
OR EXISTS (SELECT 1 FROM pg_stat_activity \
WHERE wait_event_type = 'Lock' AND query LIKE '%v2_job_queue%')",
)
.bind(id)
.fetch_one(db)
.await?;
if blocked {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
anyhow::bail!("the completion never reached the delete, so the race was not exercised")
}
/// The same cancel, one step earlier: still being written when the completion reads the queue row,
/// which reads it without a lock. Only the delete that follows waits for the writer.
#[sqlx::test(fixtures("base"))]
async fn a_cancel_still_being_written_is_kept(db: Pool<Postgres>) -> anyhow::Result<()> {
let running = start_perpetual_run(&db).await?;
let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap();
let mut cancel = db.begin().await?;
sqlx::query(
"UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1",
)
.bind(running)
.execute(&mut *cancel)
.await?;
let completing = tokio::spawn({
let db = db.clone();
async move {
add_completed_job(
&db,
&job,
true,
false,
Json(&json!("done")),
None,
0,
None,
false,
None,
false,
)
.await
}
});
// Committing on a timer would let a slow completion read the cancel through the insert
// instead, leaving the half this test is for unexercised and green. Waiting for the delete to
// block on the row is what puts it there.
wait_until_blocked_on(&db, running).await?;
cancel.commit().await?;
completing.await??;
let (status, canceled_by): (String, Option<String>) =
sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1")
.bind(running)
.fetch_one(&db)
.await?;
assert_eq!(status, "canceled", "the run is recorded as canceled");
assert_eq!(canceled_by.as_deref(), Some("test-user"));
let queued: Vec<Uuid> = sqlx::query_scalar(
"SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \
WHERE j.workspace_id = $1 AND j.runnable_path = $2",
)
.bind(W_ID)
.bind(PATH)
.fetch_all(&db)
.await?;
assert!(
queued.is_empty(),
"a canceled perpetual run queues no next one: {queued:?}"
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn a_cancel_landing_after_the_worker_read_its_row_is_kept(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let running = start_perpetual_run(&db).await?;
sqlx::query(
"UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1",
)
.bind(running)
.execute(&db)
.await?;
let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap();
add_completed_job(
&db,
&job,
true,
false,
Json(&json!("done")),
None,
0,
// What the worker carries when the cancel landed after it last read the row.
None,
false,
None,
false,
)
.await?;
let (status, canceled_by): (String, Option<String>) =
sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1")
.bind(running)
.fetch_one(&db)
.await?;
assert_eq!(status, "canceled", "the run is recorded as canceled");
assert_eq!(canceled_by.as_deref(), Some("test-user"));
let queued: Vec<Uuid> = sqlx::query_scalar(
"SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \
WHERE j.workspace_id = $1 AND j.runnable_path = $2",
)
.bind(W_ID)
.bind(PATH)
.fetch_all(&db)
.await?;
assert!(
queued.is_empty(),
"a canceled perpetual run queues no next one: {queued:?}"
);
Ok(())
}
+76
View File
@@ -0,0 +1,76 @@
//! `GET /raw_apps/list` must honor a path-scoped token. The route layer only checks
//! the scope domain and RLS knows nothing of token scopes, so the per-path filter
//! lives in the handler, ahead of the page's LIMIT.
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn list_paths(port: u16, token: &str, query: &str) -> anyhow::Result<Vec<String>> {
let resp = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/raw_apps/list?{query}"
))
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
assert_eq!(resp.status(), 200, "list: {}", resp.text().await?);
let body: Vec<serde_json::Value> = resp.json().await?;
let mut paths: Vec<String> = body
.iter()
.map(|a| a["path"].as_str().unwrap().to_string())
.collect();
paths.sort();
Ok(paths)
}
#[sqlx::test(fixtures("base"))]
async fn test_raw_apps_list_filters_by_token_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// The list sorts by edited_at desc, so the out-of-scope apps fill the first page
// unless the scope is applied before the LIMIT.
for (path, age_minutes) in [
("f/other/c", 0),
("u/test-userx/d", 1),
("u/test-user/a", 2),
("u/test-user/sub/b", 3),
// A `prefix/*` grant also covers the path at the prefix itself.
("u/test-user", 4),
] {
sqlx::query(
"INSERT INTO raw_app (path, workspace_id, data, edited_at)
VALUES ($1, 'test-workspace', '', now() - make_interval(mins => $2))",
)
.bind(path)
.bind(age_minutes)
.execute(&db)
.await?;
}
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, scopes)
VALUES (encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN',
'test@windmill.dev', 'scoped', ARRAY['raw_apps:read:u/test-user/*'])",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The same user through an unscoped token sees every row.
assert_eq!(
list_paths(port, "SECRET_TOKEN", "").await?,
[
"f/other/c",
"u/test-user",
"u/test-user/a",
"u/test-user/sub/b",
"u/test-userx/d"
]
);
assert_eq!(
list_paths(port, "SCOPED_TOKEN", "per_page=3").await?,
["u/test-user", "u/test-user/a", "u/test-user/sub/b"]
);
Ok(())
}
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_api_client::types::{NewScript, ScriptLang};
use windmill_test_utils::init_client;
const PATH: &str = "u/test-user/inferred_schema";
fn bun_ns(content: &str, parent_hash: Option<String>) -> NewScript {
NewScript {
draft_only: None,
content: content.into(),
language: ScriptLang::Bun,
lock: None,
parent_hash,
path: PATH.into(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
envs: vec![],
is_template: None,
kind: None,
summary: "".to_string(),
tag: None,
// What this client sends for "no schema".
schema: HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_secs: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
auto_kind: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
modules: None,
}
}
async fn head(db: &Pool<Postgres>) -> anyhow::Result<(i64, Value)> {
Ok(sqlx::query_as(
"SELECT hash, schema::jsonb FROM script \
WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false",
)
.bind(PATH)
.fetch_one(db)
.await?)
}
/// A deploy without a schema (MCP, a bare API call) gets the one the editor would
/// have sent, and a new version keeps what the user wrote on the previous one's.
#[sqlx::test(fixtures("base"))]
async fn schemaless_deploy_infers_schema_keeping_annotations(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (client, _port, _s) = init_client(db.clone()).await;
client
.create_script(
"test-workspace",
&bun_ns(
"export function main(n: number, name = \"x\") { return n }",
None,
),
)
.await?;
let (hash, schema) = head(&db).await?;
assert_eq!(schema["required"], json!(["n"]));
assert_eq!(schema["properties"]["n"]["type"], "number");
assert_eq!(schema["properties"]["name"]["default"], "x");
sqlx::query(
"UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \
'\"how many\"')::json WHERE hash = $1",
)
.bind(hash)
.execute(&db)
.await?;
client
.create_script(
"test-workspace",
&bun_ns(
"export function main(n: number, flag: boolean) { return n }",
Some(format!("{hash:016x}")),
),
)
.await?;
let (hash, schema) = head(&db).await?;
assert_eq!(schema["required"], json!(["n", "flag"]));
assert_eq!(schema["properties"]["n"]["description"], "how many");
assert_eq!(schema["properties"]["flag"]["type"], "boolean");
assert!(schema["properties"].get("name").is_none());
// Code that does not parse keeps the schema rather than losing it for good.
client
.create_script(
"test-workspace",
&bun_ns(
"export function main(n: number {",
Some(format!("{hash:016x}")),
),
)
.await?;
assert_eq!(head(&db).await?.1, schema);
Ok(())
}
+282 -14
View File
@@ -5439,7 +5439,7 @@ async fn test_duckdb_ffi(db: Pool<Postgres>) -> anyhow::Result<()> {
}
/// Test that flow substeps with tags that are not available for the workspace fail.
/// This validates that `check_tag_available_for_workspace_internal` is properly called
/// This validates that `check_tag_available_for_push` is properly called
/// when pushing jobs from worker_flow.
#[sqlx::test(fixtures("base"))]
#[serial]
@@ -5512,17 +5512,178 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
Ok(())
}
/// A step's `$flow_expr[...]` tag is checked on the queue it resolves to, not on its text: the
/// custom tags list `bun` alone, which admits the step resolving to it and refuses the one that
/// lands elsewhere.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
#[serial]
async fn test_flow_substep_tag_checked_on_resolved_value(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![
"bun".to_string()
])));
let step = |id: &str, tag: Option<&str>| {
json!({
"id": id,
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return { lang: 'bun' } }",
"tag": tag,
},
})
};
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
step("a", None),
step("b", Some("$flow_expr[results.a.lang]")),
step("c", Some("$flow_expr[results.a.lang]-gpu")),
],
}))?;
// A non-superadmin, so the custom tags apply.
let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
.as_user("test-user-2", "test2@windmill.dev")
.run_until_complete(&db, false, server.addr.port())
.await;
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default()));
let b_tag = sqlx::query_scalar::<_, String>(
"SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'",
)
.bind(job.id)
.fetch_one(&db)
.await?;
assert_eq!(b_tag, "bun");
assert!(!job.success);
let result = job.json_result().unwrap();
let message = result["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("(resolved to bun-gpu) is not included in the allowed CUSTOM_TAGS"),
"got {result:?}"
);
Ok(())
}
/// A flow whose preprocessor runs lands on its tag filled in from the preprocessor's output, so
/// running it judges the tag as written: even raw args that fill it in to a listed tag do not
/// admit a template nobody listed.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base", "hello"))]
#[serial]
async fn test_flow_tag_judged_as_written_before_preprocessor(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let set_flow_tag = |tag: &'static str| {
let db = db.clone();
async move {
sqlx::query("UPDATE flow SET tag = $1 WHERE path = 'f/system/hello_with_preprocessor'")
.bind(tag)
.execute(&db)
.await?;
// Flow info is cached per version, which a real redeploy would bump.
windmill_common::FLOW_INFO_CACHE
.remove(&("test-workspace".to_string(), 1443253234253456));
anyhow::Ok(())
}
};
// As a non-superadmin, so the custom tags apply.
let run_as_user = |entries: &[&str], query: &str| {
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(
entries.iter().map(|e| e.to_string()).collect(),
)));
reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/run/f/f/system/hello_with_preprocessor{query}"
))
.bearer_auth("SECRET_TOKEN_2")
.json(&json!({ "foo": "bar" }))
.send()
};
sqlx::query(
"UPDATE flow SET extra_perms = '{\"u/test-user-2\": false}'
WHERE path = 'f/system/hello_with_preprocessor'",
)
.execute(&db)
.await?;
set_flow_tag("pp-$args[foo]").await?;
let refused = run_as_user(&["pp-bar"], "").await?;
let refused_status = refused.status();
let refused_body = refused.text().await?;
// `push` drops a `?tag=` of such a flow, so it does not stand in for the flow's own tag.
let overridden = run_as_user(&["pp-bar"], "?tag=pp-bar").await?.status();
let admitted = run_as_user(&["pp-$args[foo]"], "").await?.status();
// A tag the preprocessor cannot change is known now, so patterns apply to it.
set_flow_tag("pp-large").await?;
let static_admitted = run_as_user(&["pp-$args[size]"], "").await?.status();
// Nothing a flow's tag can read resolves `$flow_expr[...]`, so the flow keeps its tag.
set_flow_tag("$flow_expr[flow_input.foo]").await?;
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default()));
let unresolvable = RunJob::from(JobPayload::Flow {
path: "f/system/hello_with_preprocessor".to_string(),
dedicated_worker: None,
apply_preprocessor: true,
version: 1443253234253456,
labels: None,
})
.as_user("test-user-2", "test2@windmill.dev")
.run_until_complete(&db, false, port)
.await;
assert_eq!(refused_status, 400, "got {refused_body}");
assert!(
refused_body.contains("Tag pp-$args[foo] is not included"),
"got {refused_body}"
);
assert_eq!(overridden, 400);
assert!(admitted.is_success(), "got {admitted}");
assert!(static_admitted.is_success(), "got {static_admitted}");
assert!(unresolvable.success, "got {:?}", unresolvable.json_result());
let tag = sqlx::query_scalar::<_, String>("SELECT tag FROM v2_job WHERE id = $1")
.bind(unresolvable.id)
.fetch_one(&db)
.await?;
assert_eq!(tag, "flow");
Ok(())
}
/// The `*` fork marker only grants through a real `parent_workspace_id` lineage lookup, which the
/// parse-level unit tests cannot reach: they hand `applies_to_workspace` a synthetic chain, so a
/// regression in the lookup or in the `is_fork_scoped()` gate that skips it would pass them.
#[sqlx::test(fixtures("base"))]
#[serial]
async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::jobs::check_tag_available_for_workspace_internal;
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
initialize_tracing().await;
// For a non-superadmin caller (a superadmin would bypass the scope check entirely).
async fn allowed(db: &Pool<Postgres>, w_id: &str, tag: &str) -> bool {
let no_args = std::collections::HashMap::new();
let args = windmill_queue::PushArgs::from(&no_args);
windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None)
.await
.is_ok()
}
// The ancestor chain is cached process-wide by workspace id, so use one no other test takes.
let fork = "wm-fork-tagmarker";
sqlx::query!(
@@ -5538,27 +5699,18 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> a
"bare(test-workspace)".to_string(),
])));
// A non-superadmin caller (a superadmin would bypass the scope check entirely).
let is_super_admin = false;
for (w_id, tag) in [("test-workspace", "bare"), ("test-workspace", "forky")] {
assert!(
check_tag_available_for_workspace_internal(&db, w_id, tag, is_super_admin, None)
.await
.is_ok(),
allowed(&db, w_id, tag).await,
"{tag} should be available in the workspace it names"
);
}
assert!(
check_tag_available_for_workspace_internal(&db, fork, "forky", is_super_admin, None)
.await
.is_ok(),
allowed(&db, fork, "forky").await,
"a `*` tag must be granted to a fork through its parent lineage"
);
assert!(
check_tag_available_for_workspace_internal(&db, fork, "bare", is_super_admin, None)
.await
.is_err(),
!allowed(&db, fork, "bare").await,
"an unmarked tag must not reach a fork of the workspace it names"
);
@@ -5567,6 +5719,122 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> a
Ok(())
}
/// A workspace-scoped pattern admits the tags in its range only from the workspaces its scope
/// allows, and a tag listed by its own name with a scope stays inside that scope whatever pattern
/// it fits. The fork lineage and `$workspace` are resolved for a matching pattern.
#[sqlx::test(fixtures("base"))]
#[serial]
async fn test_scoped_custom_tag_pattern_admission(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
initialize_tracing().await;
// For a non-superadmin caller (a superadmin would bypass the scope check entirely).
async fn check(db: &Pool<Postgres>, w_id: &str, tag: &str, size: &str) -> Result<(), String> {
let args = std::collections::HashMap::from([(
"size".to_string(),
serde_json::value::to_raw_value(size).unwrap(),
)]);
let args = windmill_queue::PushArgs::from(&args);
windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None)
.await
.map_err(|e| e.to_string())
}
async fn allowed(db: &Pool<Postgres>, w_id: &str, tag: &str, size: &str) -> bool {
check(db, w_id, tag, size).await.is_ok()
}
async fn allowed_as_written(db: &Pool<Postgres>, w_id: &str, tag: &str) -> bool {
windmill_common::jobs::check_tag_available_for_workspace_internal(
db,
w_id,
tag,
None,
std::future::ready(w_id.to_string()),
false,
None,
)
.await
.is_ok()
}
// The ancestor chain is cached process-wide by workspace id, so use one no other test takes.
let fork = "wm-fork-tagpattern";
sqlx::query!(
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ($1, $1, 'test-user', 'test-workspace')",
fork
)
.execute(&db)
.await?;
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![
"gpu-$args[size]".to_string(),
"$args[size]-secret(test-workspace)".to_string(),
"gpu-secret(other)".to_string(),
"$workspace-$args[size](test-workspace*)".to_string(),
"urgent".to_string(),
"urgent(other)".to_string(),
])));
let confined = check(&db, "test-workspace", "gpu-$args[size]", "secret").await;
let checks = [
(
"scoped pattern from its workspace",
allowed(&db, "test-workspace", "cpu-secret", "").await,
),
(
"scoped pattern refused elsewhere",
!allowed(&db, "other", "cpu-secret", "").await,
),
("confined tag refused past the patterns it fits", confined.is_err()),
(
"global pattern for the tags nothing confines",
allowed(&db, "test-workspace", "gpu-large", "").await,
),
(
"confined tag from its workspace",
allowed(&db, "other", "gpu-secret", "").await,
),
// Listing the tag by name without a scope as well keeps it open everywhere.
(
"tag listed both globally and scoped",
allowed(&db, "test-workspace", "urgent", "").await,
),
// The fork's `$workspace` is its parent's, whose tags its lineage reaches. Written as the
// resolved tag, not as the entry, so only the pattern can admit it.
(
"fork-scoped `$workspace` pattern from a fork",
allowed(&db, fork, "test-workspace-large", "").await,
),
(
"fork-scoped `$workspace` pattern refused elsewhere",
!allowed(&db, "other", "$workspace-$args[size]", "large").await,
),
(
"as written from its workspace",
allowed_as_written(&db, "test-workspace", "$args[size]-secret").await,
),
(
"as written refused elsewhere",
!allowed_as_written(&db, "other", "$args[size]-secret").await,
),
];
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default()));
for (what, ok) in checks {
assert!(ok, "{what}");
}
let confined = confined.unwrap_err();
assert!(
confined.contains("the custom tag gpu-secret(other) restricts it to other workspaces")
&& confined.contains("gpu-$args[size]")
&& confined.contains("$args[size]-secret(test-workspace)"),
"got {confined}"
);
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_whileloop_propagates_inner_iterator_eval_failure(
+76 -5
View File
@@ -1073,7 +1073,8 @@ fn no_auth_admin_authed() -> ApiAuthed {
}
/// Resolves OptJobAuthed from request parts.
/// Takes ownership of Parts and returns them back.
/// Takes ownership of Parts and returns them back, memoized into the extensions so a
/// second extraction in the same request is free.
#[allow(unreachable_code, unused_mut)]
pub async fn resolve_opt_job_authed(
mut parts: Parts,
@@ -1089,10 +1090,11 @@ pub async fn resolve_opt_job_authed(
));
}
let already_authed = parts.extensions.get::<OptJobAuthed>().cloned();
if let Some(authed) = already_authed {
return Ok((authed, parts));
// Safe only while what the route checks below read is fixed before the first
// extraction: path and method always are, `workspace_id` only while every
// `GatewayWorkspaceId` writer stays layered outside auth extraction.
if let Some(opt_job_authed) = parts.extensions.get::<OptJobAuthed>().cloned() {
return Ok((opt_job_authed, parts));
}
let already_tokened = parts.extensions.get::<Tokened>().cloned();
@@ -1185,6 +1187,9 @@ pub async fn resolve_opt_job_authed(
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
// Separate from the ApiAuthed insert above, which handlers taking
// `Extension<ApiAuthed>` read.
parts.extensions.insert(opt_job_authed.clone());
return Ok((opt_job_authed, parts));
}
}
@@ -1311,3 +1316,69 @@ pub async fn list_tokens_internal(
Ok(Json(tokens))
}
#[cfg(test)]
mod tests {
use super::*;
/// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo
/// hit observable: only a working memo can answer the second one.
#[tokio::test]
async fn resolve_opt_job_authed_is_memoized() {
// Never connected: an AUTH_CACHE hit resolves without a query, and AuthCache
// only needs the handle to exist. Without the timeout a regression — which
// falls through to a real lookup — spends sqlx's 30s default failing.
let db = sqlx::postgres::PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_millis(50))
.connect_lazy("postgres://memo@127.0.0.1:1/memo")
.expect("lazy pool");
let token = "memo_guard_token";
// Workspace-scoped: a job token on a workspace-less route is refused by
// check_job_token_for_global_route before the memo is ever taken.
let key = ("memo_workspace".to_string(), token.to_string());
let job_id = uuid::Uuid::from_u128(0x6d656d6f);
AUTH_CACHE.insert(
key.clone(),
ExpiringAuthCache {
authed: ApiAuthed {
email: "memo@windmill.dev".to_string(),
username: "memo".to_string(),
..Default::default()
},
expiry: chrono::Utc::now() + chrono::Duration::hours(1),
job_id: Some(job_id),
},
);
#[cfg(feature = "enterprise")]
let cache = AuthCache::new(db, None, None);
#[cfg(not(feature = "enterprise"))]
let cache = AuthCache::new(db, None);
let mut parts = http::Request::builder()
.uri("/api/w/memo_workspace/jobs/list")
.header(http::header::AUTHORIZATION, format!("Bearer {token}"))
.body(())
.unwrap()
.into_parts()
.0;
parts.extensions.insert(Arc::new(cache));
let (_, parts) = resolve_opt_job_authed(parts)
.await
.map_err(|(e, _)| e)
.expect("first resolution");
AUTH_CACHE.remove(&key);
let (second, _) = resolve_opt_job_authed(parts)
.await
.map_err(|(e, _)| e)
.expect("second resolution must hit the memo, not re-resolve");
assert_eq!(second.authed.username, "memo");
// The wrapper must survive whole: memoizing only the inner ApiAuthed and
// rebuilding around it drops the provenance forbid_elevated_job_token gates on.
assert_eq!(second.job_id, Some(job_id));
}
}
@@ -115,60 +115,6 @@ async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow:
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn confirmed_login_link_is_spent_by_the_click_not_the_page(
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");
let resp = client()
.post(format!("{base}/users/login_links"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"email": "test2@windmill.dev", "confirm": true}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let link = resp.json::<serde_json::Value>().await?;
// The URL handed out is the frontend page, not the API path that signs in on a GET.
let token = link["url"]
.as_str()
.unwrap()
.split_once("/user/login_link?token=")
.expect("confirmation page url")
.1
.to_string();
let confirm = || {
client()
.post(format!("{base}/auth/login_link/{token}"))
.send()
};
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp
.headers()
.get_all("set-cookie")
.iter()
.any(|c| c.to_str().unwrap().starts_with("token=")));
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/workspaces"
);
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp.headers().get("set-cookie").is_none());
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/login_link_expired?reason=used"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -12,7 +12,7 @@ use serde_json::json;
use sqlx::{Pool, Postgres};
use axum::http::StatusCode;
use windmill_api_auth::ApiAuthed;
use windmill_api_auth::{ApiAuthed, ScopePathFilter};
use windmill_common::{
error::Error,
variables::{build_crypt, encrypt},
@@ -661,6 +661,7 @@ async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<
None,
Some("f/test/after"),
Some(false),
ScopePathFilter::AllowAll,
)
.await?;
assert_eq!(
@@ -672,6 +673,55 @@ async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<
Ok(())
}
// ============================================================================
// 6. Token scope filtering on list
// ============================================================================
/// A `native_triggers:read:f/test/*` token must only list triggers under `f/test/`, on the `/`
/// boundary (`f/testx/` is out of scope).
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_list_native_triggers_scope_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
for (i, path) in ["f/testx/out", "f/test/in"].into_iter().enumerate() {
insert_test_script(&db, path).await?;
store_native_trigger(
&db,
"test-workspace",
ServiceName::Nextcloud,
&format!("ext-{i}"),
&NativeTriggerConfig {
script_path: path.to_string(),
is_flow: false,
webhook_token: format!("abcdefghij123456789{i}"),
},
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
None,
true,
)
.await?;
}
let triggers = list_native_triggers(
&db,
"test-workspace",
ServiceName::Nextcloud,
None,
None,
None,
None,
ScopePathFilter::Restricted { exact: vec![], prefix: vec!["f/test".to_string()] },
)
.await?;
assert_eq!(
triggers
.iter()
.map(|t| t.script_path.as_str())
.collect::<Vec<_>>(),
vec!["f/test/in"]
);
Ok(())
}
// --- parse_stop_channel_params ---
#[test]
@@ -0,0 +1,297 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::variables::{build_crypt, encrypt};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
/// The deploy target is this very server, which the proxy has no way to tell from another
/// instance: it only ever knows the configured URL, the caller's stored token, and the path.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_remote_deploy_proxy(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/remote_deploy");
// Planted rather than set through the route, which is enterprise-gated. Unlike the route, it
// deletes no token for another target, which is what a connect landing just after the
// route's cleanup leaves behind.
let set_target = |base_url: String, workspace_id: &'static str| {
let db = db.clone();
async move {
sqlx::query!(
"UPDATE workspace_settings
SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()
WHERE workspace_id = 'test-workspace'",
json!({ "base_url": base_url, "workspace_id": workspace_id })
)
.execute(&db)
.await
.unwrap();
}
};
set_target(format!("http://localhost:{port}"), "test-workspace").await;
let resp = authed(client().get(format!("{base}/target")))
.send()
.await?;
assert_eq!(resp.status(), 200);
let status = resp.json::<serde_json::Value>().await?;
assert_eq!(status["target"]["workspace_id"], "test-workspace");
assert!(status["connection"].is_null());
// Deploying before connecting names the step that is missing, rather than reaching the target
// with the caller's credentials for this instance.
let resp = authed(client().get(format!("{base}/proxy/none/users/whoami")))
.send()
.await?;
assert_eq!(resp.status(), 400);
assert!(resp.text().await?.contains("Connect to"));
let target =
json!({ "base_url": format!("http://localhost:{port}"), "workspace_id": "test-workspace" });
// A token obtained for another target is refused before it is sent anywhere.
let resp = authed(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN", "target": { "base_url": format!("http://localhost:{port}"), "workspace_id": "elsewhere" }}))
.send()
.await?;
assert_eq!(resp.status(), 400);
// A token the target refuses is not stored.
let resp = authed(client().post(format!("{base}/connect")))
.json(&json!({"token": "not-a-token", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 400);
let resp = authed(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 200);
let connection = resp.json::<serde_json::Value>().await?;
assert_eq!(connection["remote_email"], "test@windmill.dev");
let key = connection["proxy_key"].as_str().unwrap().to_string();
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
.send()
.await?;
assert_eq!(resp.status(), 200);
// Whatever the remote returns is served from this origin, so it must never render as a page.
let csp = resp.headers()["content-security-policy"]
.to_str()?
.to_string();
assert!(csp.starts_with("sandbox"), "{csp}");
assert_eq!(
resp.json::<serde_json::Value>().await?["email"],
"test@windmill.dev"
);
// A link from elsewhere rides the session cookie but cannot know the key, so it cannot spend
// the stored token.
// Nor can a credential that may not use the proxy read the key, to build such a link itself.
sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)
VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',
'test@windmill.dev', 'read only', false, true)"
)
.execute(&db)
.await?;
let resp = client()
.get(format!("{base}/target"))
.header("Authorization", "Bearer READ_ONLY_TOKEN")
.send()
.await?;
assert_eq!(resp.status(), 200);
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
let guessed = "x".repeat(key.len());
let resp = authed(client().get(format!("{base}/proxy/{guessed}/users/whoami")))
.send()
.await?;
assert_eq!(resp.status(), 400);
// A connect locks nothing against a key rotation, so its row can land under the old key: that
// is no connection, which the drawer offers to replace, rather than an error on every deploy.
sqlx::query!(
"UPDATE remote_deploy_token SET token = 'not-under-this-key'
WHERE workspace_id = 'test-workspace'"
)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/target")))
.send()
.await?;
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
// A target that refuses the stored token must not answer 401: the browser reads an
// unhandled 401 as its own session having expired and logs the user out of this instance.
let mc = build_crypt(&db, "test-workspace").await?;
sqlx::query!(
"UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'",
encrypt(&mc, "revoked-token")
)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
.send()
.await?;
assert_eq!(resp.status(), 502);
// Nor does a connect lock anything against a removal from the workspace, so its row can land
// after the removal cleared the table: a row from an earlier membership must not come back
// with a re-add, even one whose `created_at` reads earlier than the connect (it is the start
// of the re-adding transaction).
let as_test2 =
|builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer SECRET_TOKEN_2");
let resp = as_test2(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN_2", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 200);
let key2 = resp.json::<serde_json::Value>().await?["proxy_key"]
.as_str()
.unwrap()
.to_string();
let resp = as_test2(client().get(format!("{base}/target")))
.send()
.await?;
assert_eq!(
resp.json::<serde_json::Value>().await?["connection"]["proxy_key"],
key2.as_str()
);
sqlx::query!(
"UPDATE usr SET created_at = created_at - interval '1 hour'
WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'"
)
.execute(&db)
.await?;
let resp = as_test2(client().get(format!("{base}/target")))
.send()
.await?;
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
let resp = as_test2(client().get(format!("{base}/proxy/{key2}/users/whoami")))
.send()
.await?;
assert_eq!(resp.status(), 400);
assert!(resp.text().await?.contains("Connect to"));
// Deleting the account must take its token with it, or the next account created with that
// address would act on the remote as this one.
let resp = authed(client().delete(format!(
"http://localhost:{port}/api/users/delete/test2@windmill.dev"
)))
.send()
.await?;
assert_eq!(resp.status(), 200);
let left = sqlx::query_scalar!(
"SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'"
)
.fetch_one(&db)
.await?;
assert_eq!(left, Some(0));
// The token was granted for one target and is never sent to another, so re-pointing the
// workspace leaves the caller unconnected instead of handing its token to the new target.
set_target(format!("http://localhost:{port}"), "other-workspace").await;
let resp = authed(client().get(format!("{base}/target")))
.send()
.await?;
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
.send()
.await?;
assert_eq!(resp.status(), 400);
// The row for the old target outlived the change, as one from a connect in flight across it
// would: pointing the setting back must not revive it, even with a stamp that reads earlier
// than the connect.
set_target(format!("http://localhost:{port}"), "test-workspace").await;
sqlx::query!(
"UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'
WHERE workspace_id = 'test-workspace'"
)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/target")))
.send()
.await?;
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
// A connect in flight across a disconnect must not undo it when it lands. The disconnect is
// stamped an hour ahead, as if every connect starting now had started before it.
let resp = authed(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 200);
let resp = authed(client().post(format!("{base}/disconnect")))
.send()
.await?;
assert_eq!(resp.status(), 200);
sqlx::query!(
"UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'
WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'"
)
.execute(&db)
.await?;
let resp = authed(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 400);
let resp = authed(client().get(format!("{base}/target")))
.send()
.await?;
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
// A superadmin outside the workspace has no membership to bind to, so the row is bound to the
// account by the credential making the request: an account deleted during the remote call,
// and its address taken by another, must not inherit it. A deleted credential that auth still
// holds in its cache stands in for one whose account went away mid-call.
sqlx::query!(
"DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'"
)
.execute(&db)
.await?;
sqlx::query!("DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'")
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)
VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',
'test@windmill.dev', 'gone', true)"
)
.execute(&db)
.await?;
let as_gone =
|builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer GONE_TOKEN");
let resp = as_gone(client().get(format!("{base}/target")))
.send()
.await?;
assert_eq!(resp.status(), 200);
sqlx::query!("DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'")
.execute(&db)
.await?;
let resp = as_gone(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 400);
let resp = authed(client().post(format!("{base}/connect")))
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
.send()
.await?;
assert_eq!(resp.status(), 200);
Ok(())
}
@@ -319,6 +319,14 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
assert_eq!(resp.status(), 200);
let resp = authed(client().post(format!("{base}/edit_webhook")))
.json(&json!({"webhook": "http://127.0.0.1/hook"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert!(resp.text().await?.contains("ALLOW_PRIVATE_WEBHOOK_URLS"));
// --- edit_auto_invite (EE-gated) ---
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
.json(&json!({"operator": false, "invite_all": false, "auto_add": false}))
+55 -10
View File
@@ -40,8 +40,8 @@ use windmill_common::{
FlowVersionInfo, DB,
};
use windmill_queue::{
cancel_job, get_result_and_success_by_id_from_flow, parse_result_object, push, PushArgs,
PushArgsOwned, PushIsolationLevel,
cancel_job, check_tag_available_for_push, get_result_and_success_by_id_from_flow,
parse_result_object, push, tag_reads_args, PushArgs, PushArgsOwned, PushIsolationLevel,
};
use crate::types::RunJobQuery;
@@ -50,7 +50,27 @@ use crate::types::RunJobQuery;
// Tag / license checks
// ---------------------------------------------------------------------------
/// `args` must be the ones the job is pushed with, see [`windmill_queue::check_tag_available_for_push`].
pub async fn check_tag_available_for_workspace(
db: &DB,
w_id: &str,
tag: &Option<String>,
args: &PushArgs<'_>,
authed: &ApiAuthed,
) -> error::Result<()> {
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
let tags = get_scope_tags(authed);
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
check_tag_available_for_push(db, w_id, tag, args, is_super_admin, tags).await
} else {
Ok(())
}
}
/// [`check_tag_available_for_workspace`] for a tag filled in from values nothing can check yet,
/// such as a flow's preprocessor output: only an entry spelled exactly like it admits it.
pub async fn check_tag_as_written_available_for_workspace(
db: &DB,
w_id: &str,
tag: &Option<String>,
@@ -60,7 +80,16 @@ pub async fn check_tag_available_for_workspace(
let tags = get_scope_tags(authed);
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
check_tag_available_for_workspace_internal(db, w_id, tag, is_super_admin, tags).await
check_tag_available_for_workspace_internal(
db,
w_id,
tag,
None,
std::future::ready(w_id.to_string()),
is_super_admin,
tags,
)
.await
} else {
Ok(())
}
@@ -760,9 +789,25 @@ pub async fn run_flow<'c>(
..
} = flow_version_info;
let tag = run_query.tag.clone().or(tag);
let flow_tag = tag;
let tag = run_query.tag.clone().or(flow_tag.clone());
let push_args = PushArgs { args: &args.args, extra: args.extra };
let apply_preprocessor =
!run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false);
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
if !apply_preprocessor || run_query.tag.is_some() {
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
}
// `push` drops the tag of a flow whose preprocessor runs, and the worker then puts the flow on
// its own tag filled in from the preprocessor's output, after this caller's identity is gone.
// So that tag is the one to check, and an `$args[...]` in it is judged as written.
if apply_preprocessor {
if flow_tag.as_deref().is_some_and(tag_reads_args) {
check_tag_as_written_available_for_workspace(&db, &w_id, &flow_tag, &authed).await?;
} else {
check_tag_available_for_workspace(&db, &w_id, &flow_tag, &push_args, &authed).await?;
}
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let return_tx = tx_o.is_some();
@@ -798,11 +843,10 @@ pub async fn run_flow<'c>(
path: flow_path.to_string(),
dedicated_worker,
version,
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
&& has_preprocessor.unwrap_or(false),
apply_preprocessor,
labels,
},
PushArgs { args: &args.args, extra: args.extra },
push_args,
authed.display_username(),
email,
permissioned_as,
@@ -988,7 +1032,8 @@ pub async fn push_script_job_by_path_into_queue<'c>(
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let return_tx = tx_o.is_some();
@@ -1020,7 +1065,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
push_args,
authed.display_username(),
email,
permissioned_as,
+1
View File
@@ -619,6 +619,7 @@ async fn edit_schedule(
dynamic_skip = $23,
email = $24,
edited_by = $25,
edited_at = now(),
permissioned_as = $26,
labels = COALESCE($27, labels)
WHERE path = $19 AND workspace_id = $20
+4
View File
@@ -30,7 +30,11 @@ windmill-parser-ts-asset.workspace = true
windmill-parser-sql-asset.workspace = true
windmill-parser-sql.workspace = true
windmill-parser-yaml.workspace = true
windmill-parser-bash.workspace = true
windmill-parser-go.workspace = true
windmill-parser-graphql.workspace = true
anyhow.workspace = true
axum.workspace = true
futures.workspace = true
http.workspace = true
+124
View File
@@ -1132,6 +1132,96 @@ async fn validate_dbt_relation(
})
}
/// The schema the editor would have derived from `content`, for a deploy that sends
/// none (MCP, a bare API call). `None` for a language whose parser this build lacks,
/// or code that does not parse.
fn infer_main_schema(
language: &ScriptLang,
kind: Option<&ScriptKind>,
content: &str,
previous: Option<&serde_json::Value>,
) -> Option<Schema> {
let entrypoint =
matches!(kind, Some(ScriptKind::Preprocessor)).then(|| "preprocessor".to_string());
// The database a SQL script runs against is an argument unless the code names
// it (`-- database f/...`), as in the editor.
let with_db = |sig: anyhow::Result<windmill_parser::MainArgSignature>, resource: &str| {
sig.map(|mut sig| {
if windmill_parser_sql::parse_db_resource(content).is_none() {
sig.args.insert(
0,
windmill_parser::Arg {
name: "database".to_string(),
typ: windmill_parser::Typ::Resource(resource.to_string()),
..Default::default()
},
);
}
sig
})
};
let sig = match language {
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
windmill_parser_ts::parse_deno_signature(content, false, false, entrypoint)
}
#[cfg(feature = "python")]
ScriptLang::Python3 => {
windmill_parser_py::parse_python_signature(content, entrypoint, false)
}
ScriptLang::Go => windmill_parser_go::parse_go_sig(content),
ScriptLang::Bash => windmill_parser_bash::parse_bash_sig(content),
ScriptLang::Powershell => windmill_parser_bash::parse_powershell_sig(content),
ScriptLang::Postgresql => {
with_db(windmill_parser_sql::parse_pgsql_sig(content), "postgresql")
}
ScriptLang::Mysql => with_db(windmill_parser_sql::parse_mysql_sig(content), "mysql"),
ScriptLang::Bigquery => {
with_db(windmill_parser_sql::parse_bigquery_sig(content), "bigquery")
}
ScriptLang::Snowflake => with_db(
windmill_parser_sql::parse_snowflake_sig(content),
"snowflake",
),
ScriptLang::Mssql => with_db(
windmill_parser_sql::parse_mssql_sig(content),
"ms_sql_server",
),
ScriptLang::OracleDB => {
with_db(windmill_parser_sql::parse_oracledb_sig(content), "oracledb")
}
ScriptLang::DuckDb => windmill_parser_sql::parse_duckdb_sig(content),
ScriptLang::Graphql => {
windmill_parser_graphql::parse_graphql_sig(content).map(|mut sig| {
sig.args.insert(
0,
windmill_parser::Arg {
name: "api".to_string(),
typ: windmill_parser::Typ::Resource("graphql".to_string()),
..Default::default()
},
);
sig
})
}
ScriptLang::Ansible => windmill_parser_yaml::parse_ansible_sig(content),
_ => return None,
};
match sig {
Ok(sig) => serde_json::value::to_raw_value(&windmill_parser::json_schema::main_arg_schema(
&sig, previous,
))
.ok()
.map(|v| Schema(sqlx::types::Json(v))),
Err(e) => {
tracing::warn!(
"could not infer the schema of a {} script: {e:#}",
language.as_str()
);
None
}
}
}
async fn create_script_internal<'c>(
mut ns: NewScript,
w_id: String,
@@ -1359,6 +1449,40 @@ async fn create_script_internal<'c>(
parent_adopted_from_retired_path = ns.parent_hash.is_some();
}
// Before hashing, so the hash and the no-op check see the schema that gets stored.
// `{}` counts as absent: an agent filling every tool argument sends it for "none".
let schema_absent = ns.schema.as_ref().is_none_or(|s| {
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(s.0.get())
.is_ok_and(|m| m.is_empty())
});
if schema_absent && !matches!(ns.language, ScriptLang::Dbt) {
let previous = match &ns.parent_hash {
Some(p_hash) => sqlx::query_scalar::<_, Option<sqlx::types::Json<serde_json::Value>>>(
"SELECT schema FROM script WHERE hash = $1 AND workspace_id = $2",
)
.bind(p_hash.0)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.flatten()
.map(|s| s.0),
None => None,
};
// Code the server cannot parse keeps the previous version's schema, as it
// does in the editor, rather than losing it for good.
ns.schema = infer_main_schema(
&ns.language,
ns.kind.as_ref(),
&ns.content,
previous.as_ref(),
)
.or_else(|| {
previous
.and_then(|p| serde_json::value::to_raw_value(&p).ok())
.map(|v| Schema(sqlx::types::Json(v)))
});
}
// Must stay below the parent resolution above: an auto_parent deploy hashed before
// it carries a first deploy's lineage, so redeploying content the path has held
// before collides with that archived version instead of superseding it. The
+21 -3
View File
@@ -58,9 +58,9 @@ use windmill_common::{
error::{self, pg_error_message, JsonResult, Result},
get_database_url,
global_settings::{
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,
ACCENT_COLOR_SETTING, 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_DEFAULT_ALLOWED_ORIGINS_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
@@ -115,6 +115,17 @@ async fn get_ruff_config_unauthed(Extension(db): Extension<DB>) -> error::Result
.unwrap())
}
/// The announcement banner and accent color, which every signed-in session reads on each
/// full page load.
async fn get_instance_ui(
Extension(db): Extension<DB>,
_authed: ApiAuthed,
) -> JsonResult<windmill_common::global_settings::InstanceUi> {
Ok(Json(
windmill_common::global_settings::get_instance_ui(&db).await?,
))
}
pub fn global_service() -> Router {
#[warn(unused_mut)]
let r = Router::new()
@@ -126,6 +137,7 @@ pub fn global_service() -> Router {
"/global/{key}",
post(set_global_setting).get(get_global_setting),
)
.route("/instance_ui", get(get_instance_ui))
.route("/list_global", get(list_global_settings))
.route("/github_app_stale_webhooks", get(github_app_stale_webhooks))
.route(
@@ -1216,6 +1228,12 @@ async fn run_setting_pre_write_hook(
}
}
}
ACCENT_COLOR_SETTING => match value {
serde_json::Value::Null => {}
serde_json::Value::String(s) if s.trim().is_empty() => {}
v => windmill_common::global_settings::validate_accent_color(v)
.map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?,
},
_ => {}
}
Ok(())
+41 -55
View File
@@ -171,10 +171,7 @@ pub fn make_unauthed_service() -> Router {
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route(
"/login_link/{token}",
get(consume_login_link).post(confirm_login_link),
)
.route("/login_link/{token}", get(consume_login_link))
.route("/is_smtp_configured", get(is_smtp_configured))
.route(
"/is_password_login_disabled",
@@ -2566,6 +2563,14 @@ pub async fn delete_workspace_user_internal(
.execute(&mut **tx)
.await?;
sqlx::query!(
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
email_to_delete,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
username_to_delete,
@@ -3287,12 +3292,9 @@ async fn impersonate(
}
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
// Long enough for a link sent by email to still work when it is read. `require_login_type` is
// only checked at mint, so a much longer cap would need re-checking it when the link is opened.
const LOGIN_LINK_MAX_TTL_S: u32 = 7200;
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link";
#[derive(Deserialize)]
pub struct NewLoginLink {
@@ -3303,9 +3305,6 @@ pub struct NewLoginLink {
/// account it created can require `pending_oauth`, so the link stops working once the
/// owner has set a password or signed in with a provider.
pub require_login_type: Option<String>,
/// Hand out a page that signs in only when its button is clicked. Mail scanners open links
/// on delivery, and opening the plain link spends it, so a link sent by email sets this.
pub confirm: Option<bool>,
}
#[derive(Serialize)]
@@ -3456,12 +3455,11 @@ async fn create_login_link(
.await?;
tx.commit().await?;
let base_url = (**BASE_URL.load()).clone();
let url = if nl.confirm.unwrap_or(false) {
format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}")
} else {
format!("{base_url}/api/auth/login_link/{token}")
};
let url = format!(
"{}/api/auth/login_link/{}",
(**BASE_URL.load()).clone(),
token
);
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
}
@@ -3707,45 +3705,19 @@ async fn consume_login_link(
Path(token): Path<String>,
Query(query): Query<LoginLinkQuery>,
) -> Result<Response> {
let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?;
Ok(login_link_redirect(location))
}
#[derive(Serialize)]
struct LoginLinkLocation {
location: String,
}
/// The confirmation page's click. It answers with where to go rather than redirecting, and the
/// page navigates there itself.
async fn confirm_login_link(
headers: axum::http::HeaderMap,
cookies: Cookies,
Extension(db): Extension<DB>,
Path(token): Path<String>,
) -> JsonResult<LoginLinkLocation> {
let location = redeem_login_link(&headers, cookies, &db, &token, None).await?;
Ok(Json(LoginLinkLocation { location }))
}
/// Spends the link and sets the session cookie, returning the post-login destination; or
/// returns the explanation page, with no session, when the link cannot be used.
async fn redeem_login_link(
headers: &axum::http::HeaderMap,
cookies: Cookies,
db: &DB,
token: &str,
requested_rd: Option<String>,
) -> Result<String> {
let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"));
let bounce = |reason: &str| {
Ok(login_link_redirect(format!(
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
)))
};
if token.len() != 32 {
return bounce("invalid");
}
let t_hash = hash_token(token);
let t_hash = hash_token(&token);
// The account is unknown until the row is read, so only the global and per-IP tiers
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
windmill_common::login_rate_limit::check_and_increment_login_attempt(
headers,
&headers,
&t_hash[..TOKEN_PREFIX_LEN],
)?;
@@ -3812,10 +3784,11 @@ async fn redeem_login_link(
.await?;
tx.commit().await?;
Ok(link
let rd = link
.rd
.or_else(|| same_origin_rd(requested_rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()))
.or_else(|| same_origin_rd(query.rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
Ok(login_link_redirect(rd))
}
#[derive(Deserialize)]
@@ -4050,6 +4023,7 @@ async fn update_token_label(
AND NOT starts_with(label, 'sdk_app:')
AND NOT starts_with(label, 'impersonation:')
AND NOT starts_with(label, 'cli-login:')
AND NOT starts_with(label, 'remote-deploy:')
))
RETURNING token_prefix",
req.label.as_deref(),
@@ -4096,13 +4070,25 @@ async fn leave_workspace(
&format!("u/{}", authed.username),
)
.await?;
sqlx::query!(
let left = sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
&w_id,
authed.username
)
.execute(&mut *tx)
.await?;
.await?
.rows_affected();
// A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop
// its connection.
if left > 0 {
sqlx::query!(
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
&authed.email,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -35,6 +35,7 @@ windmill-store.workspace = true
axum.workspace = true
chrono.workspace = true
constant_time_eq.workspace = true
futures = { workspace = true, optional = true }
hex.workspace = true
magic-crypt.workspace = true
@@ -42,6 +43,7 @@ http.workspace = true
hyper.workspace = true
lazy_static.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
@@ -49,5 +51,6 @@ sqlx.workspace = true
tokio.workspace = true
tokio-postgres.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
strum.workspace = true
@@ -9,6 +9,7 @@ pub mod datatable_permissions;
pub mod datatable_permissions_oss;
pub mod datatable_replay_oss;
pub mod deployment_requests;
pub mod remote_deploy;
pub mod workspaces;
pub mod workspaces_extra;
pub mod workspaces_oss;
@@ -0,0 +1,794 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Deploying from the UI into a workspace of another Windmill instance.
//!
//! The browser runs the same deploy it runs between two workspaces of this instance, and sends
//! every call aimed at the target through [`proxy`], which forwards it to the remote workspace
//! with the caller's own token for that instance. The remote authorizes and audits the deploy as
//! that person, so nothing here needs to know what a deploy is made of.
use std::time::Duration;
use axum::{
body::{Body, Bytes},
extract::{DefaultBodyLimit, Extension, OriginalUri, Path},
http::{header, HeaderMap, Method, StatusCode},
response::Response,
routing::{any, get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use magic_crypt::MagicCrypt256;
use regex::Regex;
use serde::{Deserialize, Serialize};
use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened};
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
auth::hash_token,
error::{error_source_chain, Error, Result},
ssrf::validate_url_for_ssrf,
utils::{configure_client, rd_string, require_admin},
variables::{build_crypt, decrypt, encrypt},
worker::CLOUD_HOSTED,
DB,
};
lazy_static! {
static ref REMOTE_WORKSPACE_ID: Regex = Regex::new("^[a-zA-Z0-9_-]{1,50}$").unwrap();
/// Shared so that a deploy's calls reuse connections. A cloud instance instead builds a client
/// per request, pinned to the addresses it just validated for that target.
static ref REMOTE_CLIENT: reqwest::Client = remote_client_builder().build().unwrap();
}
const PROXY_PREFIX: &str = "/remote_deploy/proxy/";
pub fn workspaced_service(proxy_body_limit: usize) -> Router {
Router::new()
.route("/target", get(get_target).post(set_target))
.route("/connect", post(connect))
.route("/disconnect", post(disconnect))
// `{key}` is the caller's `proxy_key`. The session cookie is `SameSite=Lax`, so it rides a
// top-level navigation from any site: without a value such a link cannot know, it could
// spend the stored token, to run something on the remote or to render its content on this
// origin. `Sec-Fetch-Site` is no substitute, since plain-http instances never receive it.
.route(
"/proxy/{key}/{*rest}",
any(proxy).layer(DefaultBodyLimit::max(proxy_body_limit)),
)
}
/// A request URI as logs should record it: with the proxy key masked, since that key is what keeps
/// a link from spending a stored remote token.
pub struct RedactedUri<'a>(pub &'a axum::http::Uri);
impl std::fmt::Display for RedactedUri<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Some((head, keyed)) = self.0.path().split_once(PROXY_PREFIX) else {
return self.0.fmt(f);
};
let rest = keyed.find('/').map_or("", |i| &keyed[i..]);
write!(f, "{head}{PROXY_PREFIX}***{rest}")?;
match self.0.query() {
Some(query) => write!(f, "?{query}"),
None => Ok(()),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RemoteDeployTarget {
/// Root URL of the remote instance, without `/api`.
pub base_url: String,
pub workspace_id: String,
}
impl RemoteDeployTarget {
fn normalized(self) -> Result<Self> {
let base_url = self.base_url.trim().trim_end_matches('/').to_string();
let parsed = url::Url::parse(&base_url)
.map_err(|e| Error::BadRequest(format!("Invalid remote instance URL: {e}")))?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err(Error::BadRequest(
"The remote instance URL must be an http(s) URL with a host".to_string(),
));
}
if !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(Error::BadRequest(
"The remote instance URL must not carry credentials, a query or a fragment"
.to_string(),
));
}
let workspace_id = self.workspace_id.trim().to_string();
if !REMOTE_WORKSPACE_ID.is_match(&workspace_id) {
return Err(Error::BadRequest(format!(
"Invalid remote workspace id: {workspace_id}"
)));
}
Ok(Self { base_url, workspace_id })
}
fn api_url(&self, suffix: &str) -> String {
format!("{}/api/w/{}/{suffix}", self.base_url, self.workspace_id)
}
}
#[derive(Serialize)]
struct RemoteDeployConnection {
remote_email: String,
/// Goes in every proxy URL, see [`workspaced_service`].
proxy_key: String,
connected_at: DateTime<Utc>,
}
#[derive(Serialize)]
struct RemoteDeployStatus {
target: Option<RemoteDeployTarget>,
/// The caller's own connection to `target`.
connection: Option<RemoteDeployConnection>,
}
/// A stored token acts on another instance as its owner, with none of the restrictions of the
/// credential that reaches it. Only the owner acting directly with full rights may use or replace
/// it: a job token runs as whoever a `wm_deployers` member pointed `on_behalf_of` at, and a
/// scoped or read-only token was never granted this.
fn require_own_credentials(authed: &ApiAuthed) -> Result<()> {
if authed.job_id.is_some()
|| authed.read_only
|| !is_effectively_unscoped(authed.scopes.as_deref())
{
return Err(Error::PermissionDenied(
"Deploying to a remote instance requires your own session or an unscoped token, \
not a job, scoped or read-only token"
.to_string(),
));
}
Ok(())
}
fn parse_target(target: Option<serde_json::Value>) -> Result<Option<RemoteDeployTarget>> {
target
.map(serde_json::from_value)
.transpose()
.map_err(|e| Error::internal_err(format!("reading the remote deploy target: {e}")))
}
fn no_target(w_id: &str) -> Error {
Error::BadRequest(format!(
"Workspace {w_id} has no remote deploy target. An admin sets it in the workspace \
settings, under Dev workspace"
))
}
async fn load_target(db: &DB, w_id: &str) -> Result<Option<RemoteDeployTarget>> {
let target = sqlx::query_scalar!(
"SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.fetch_optional(db)
.await?
.flatten();
parse_target(target)
}
async fn require_target(db: &DB, w_id: &str) -> Result<RemoteDeployTarget> {
load_target(db, w_id).await?.ok_or_else(|| no_target(w_id))
}
fn remote_client_builder() -> reqwest::ClientBuilder {
configure_client(reqwest::ClientBuilder::new())
.user_agent("windmill/remote-deploy")
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
}
async fn remote_client(target: &RemoteDeployTarget) -> Result<reqwest::Client> {
// A workspace admin chose the URL. A self-hosted instance may deploy into its own private
// network, as it may reach a private git remote; a cloud workspace must not.
if !*CLOUD_HOSTED {
return Ok(REMOTE_CLIENT.clone());
}
validate_url_for_ssrf(&target.base_url)
.await?
.apply_dns_pinning(remote_client_builder())
.build()
.map_err(|e| Error::internal_err(format!("building the remote deploy client: {e}")))
}
fn unreachable(target: &RemoteDeployTarget, e: reqwest::Error) -> Error {
Error::BadGateway(format!(
"Could not reach the remote instance {}: {}",
target.base_url,
error_source_chain(&e)
))
}
/// Responses carrying a `proxy_key` must not be kept by any cache between here and the browser.
const NO_STORE: [(header::HeaderName, &str); 1] = [(header::CACHE_CONTROL, "no-store")];
struct StoredConnection {
token: String,
remote_email: String,
proxy_key: String,
connected_at: DateTime<Utc>,
}
/// The caller's connection to `target`, if they may still use it.
///
/// `connect` takes no lock against what clears these rows (a removal from the workspace, a target
/// change, a key rotation): writers take those rows in every order, so any lock it held could close
/// a deadlock. A row can therefore land after one of them ran, however long its remote call took,
/// and is voided here instead, by identity rather than by comparing times: it counts only while
/// the target setting and the owner's membership are the very ones it was connected under (see
/// `member_since`, `target_changed_at`) or the owner is a superadmin, and while it decrypts. An
/// emptied row is a disconnect's.
async fn load_connection(
db: &DB,
w_id: &str,
email: &str,
target: &RemoteDeployTarget,
) -> Result<Option<StoredConnection>> {
let Some(row) = sqlx::query_as!(
StoredConnection,
"SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t
JOIN workspace_settings s ON s.workspace_id = t.workspace_id
WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3
AND t.remote_workspace_id = $4 AND t.token <> ''
AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at
AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id
AND u.email = t.email AND u.created_at = t.member_since)
OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))",
w_id,
email,
&target.base_url,
&target.workspace_id
)
.fetch_optional(db)
.await?
else {
return Ok(None);
};
match decrypt(&build_crypt(db, w_id).await?, row.token) {
Ok(token) => Ok(Some(StoredConnection { token, ..row })),
Err(e) => {
tracing::warn!("remote deploy token of {email} in {w_id} does not decrypt: {e}");
Ok(None)
}
}
}
async fn get_target(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> Result<(
[(header::HeaderName, &'static str); 1],
Json<RemoteDeployStatus>,
)> {
let target = load_target(&db, &w_id).await?;
// The connection carries the proxy key. A credential that may not use the proxy (see
// `require_own_credentials`) must not read it either: it could hand its owner a working link.
let connection = match &target {
Some(target) if require_own_credentials(&authed).is_ok() => {
load_connection(&db, &w_id, &authed.email, target)
.await?
.map(|c| RemoteDeployConnection {
remote_email: c.remote_email,
proxy_key: c.proxy_key,
connected_at: c.connected_at,
})
}
_ => None,
};
Ok((NO_STORE, Json(RemoteDeployStatus { target, connection })))
}
#[derive(Deserialize)]
struct SetRemoteDeployTarget {
target: Option<RemoteDeployTarget>,
}
async fn set_target(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(request): Json<SetRemoteDeployTarget>,
) -> Result<String> {
if !cfg!(feature = "enterprise") {
return Err(Error::BadRequest(
"Deploying to another instance is only available on Windmill Enterprise Edition"
.to_string(),
));
}
// Everyone who connects afterwards hands their token to this URL.
require_own_credentials(&authed)?;
require_admin(authed.is_admin, &authed.username)?;
let target = request
.target
.map(RemoteDeployTarget::normalized)
.transpose()?;
let mut tx = db.begin().await?;
let (base_url, remote_workspace_id) = match &target {
Some(t) => (Some(t.base_url.as_str()), Some(t.workspace_id.as_str())),
None => (None, None),
};
audit_log(
&mut *tx,
&authed,
"workspaces.edit_remote_deploy_target",
ActionKind::Update,
&w_id,
None,
Some(
[
("base_url", base_url.unwrap_or("")),
("remote_workspace_id", remote_workspace_id.unwrap_or("")),
]
.into(),
),
)
.await?;
let target_json = target
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| Error::internal_err(e.to_string()))?;
sqlx::query!(
"UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,
remote_deploy_target_changed_at = CASE
WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()
ELSE remote_deploy_target_changed_at END
WHERE workspace_id = $2",
target_json,
&w_id
)
.execute(&mut *tx)
.await?;
// A token is only ever sent to the target it was granted for, so the ones for any other
// target are dead credentials.
sqlx::query!(
"DELETE FROM remote_deploy_token WHERE workspace_id = $1
AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)",
&w_id,
base_url,
remote_workspace_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(match target {
Some(t) => format!(
"Workspace {w_id} deploys to {} on {}",
t.workspace_id, t.base_url
),
None => format!("Removed the remote deploy target of workspace {w_id}"),
})
}
#[derive(Deserialize)]
struct ConnectRequest {
token: String,
/// The target the caller got the token for, as the drawer showed it.
target: RemoteDeployTarget,
}
#[derive(Deserialize)]
struct RemoteWhoami {
email: String,
}
async fn connect(
authed: ApiAuthed,
Tokened { token: credential }: Tokened,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(request): Json<ConnectRequest>,
) -> Result<(
[(header::HeaderName, &'static str); 1],
Json<RemoteDeployConnection>,
)> {
require_own_credentials(&authed)?;
// Read before the remote call, which can take long enough for any of it to change: the row is
// bound to the target version and the membership seen here (see `load_connection`), and
// stamped with when this connect started, so that a disconnect or a newer connect made while
// it runs keeps it from landing.
let start = sqlx::query!(
r#"SELECT clock_timestamp() AS "started_at!", s.remote_deploy_target,
s.remote_deploy_target_changed_at,
(SELECT u.created_at FROM usr u
WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since
FROM workspace_settings s WHERE s.workspace_id = $1"#,
&w_id,
&authed.email
)
.fetch_optional(&db)
.await?
.ok_or_else(|| no_target(&w_id))?;
let target = parse_target(start.remote_deploy_target)?.ok_or_else(|| no_target(&w_id))?;
// A token is only ever sent to the instance it was meant for. Without this, re-pointing the
// target between the user getting a token and posting it here would hand it to the new one;
// after this check the token still goes to `target` only, never to what the setting became.
let requested = request.target.normalized()?;
if requested.base_url != target.base_url || requested.workspace_id != target.workspace_id {
return Err(Error::BadRequest(
"The remote deploy target changed since you started connecting; reload and connect \
again"
.to_string(),
));
}
let token = request.token.trim();
if token.is_empty() {
return Err(Error::BadRequest("The token is empty".to_string()));
}
let response = remote_client(&target)
.await?
.get(target.api_url("users/whoami"))
.bearer_auth(token)
.send()
.await
.map_err(|e| unreachable(&target, e))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(Error::BadRequest(format!(
"{} did not accept this token for workspace {} ({status}): {body}",
target.base_url, target.workspace_id
)));
}
let whoami: RemoteWhoami = response.json().await.map_err(|e| {
Error::BadGateway(format!(
"{} did not answer like a Windmill instance: {}",
target.base_url,
error_source_chain(&e)
))
})?;
let proxy_key = rd_string(32);
let mc = build_crypt(&db, &w_id).await?;
// No lock against removals, target changes or key rotations: `load_connection` voids a row
// that lands after one of them. A disconnect or a newer connect stamps the row itself, which
// this one then leaves alone. Without a membership to bind to, the row is bound to the account
// through the credential making this request, which deleting the account removes (except
// through `leave_instance`, which leaves its tokens): the address could otherwise be deleted
// and taken by a new account while the remote call runs, and the foreign key would accept it.
let mut tx = db.begin().await?;
let connected_at = sqlx::query_scalar!(
"INSERT INTO remote_deploy_token
(workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,
connected_at, member_since, target_changed_at)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
WHERE $9::timestamptz IS NOT NULL
OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)
ON CONFLICT (workspace_id, email) DO UPDATE SET
base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,
token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,
proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,
member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at
WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at
RETURNING connected_at",
&w_id,
&authed.email,
&target.base_url,
&target.workspace_id,
encrypt(&mc, token),
&whoami.email,
&proxy_key,
start.started_at,
start.member_since,
start.remote_deploy_target_changed_at,
hash_token(&credential)
)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
Error::BadRequest(match start.member_since {
Some(_) => "This connect was not saved: you disconnected or connected again while it \
was running"
.to_string(),
None => format!(
"This connect was not saved: you disconnected, connected again or signed out \
while it was running. Connecting workspace {w_id} without being one of its \
members needs a session or an API token of this instance"
),
})
})?;
audit_log(
&mut *tx,
&authed,
"workspaces.remote_deploy_connect",
ActionKind::Create,
&w_id,
Some(&whoami.email),
Some([("base_url", target.base_url.as_str())].into()),
)
.await?;
tx.commit().await?;
Ok((
NO_STORE,
Json(RemoteDeployConnection { remote_email: whoami.email, proxy_key, connected_at }),
))
}
async fn disconnect(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> Result<String> {
require_own_credentials(&authed)?;
let mut tx = db.begin().await?;
// Emptied rather than deleted, and stamped, even with no connection yet: a connect that started
// before this must not bring one back when it lands (see `connect`).
sqlx::query!(
"INSERT INTO remote_deploy_token
(workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,
connected_at)
VALUES ($1, $2, '', '', '', '', '', clock_timestamp())
ON CONFLICT (workspace_id, email) DO UPDATE SET
token = '', proxy_key = '', connected_at = clock_timestamp()",
&w_id,
&authed.email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.remote_deploy_disconnect",
ActionKind::Delete,
&w_id,
None,
None,
)
.await?;
tx.commit().await?;
Ok(format!(
"Disconnected from the remote deploy target of {w_id}"
))
}
/// The remote URL for the request path after the proxy prefix and key, as the client sent it.
///
/// `url` rewrites a path while parsing it: it resolves dot segments (`%2e` spellings included)
/// and turns backslashes into slashes. Either would reach a route other than the one this
/// instance authorized — outside the remote workspace, or a route its read-only check does not
/// recognize — so a path the parser would change is refused rather than forwarded.
fn forwarded_url(
target: &RemoteDeployTarget,
original_path: &str,
query: Option<&str>,
) -> Result<url::Url> {
let suffix = original_path
.split_once(PROXY_PREFIX)
.and_then(|(_, keyed)| keyed.split_once('/'))
.map(|(_, suffix)| suffix)
.unwrap_or_default();
let invalid = || Error::BadRequest(format!("Invalid remote deploy path: {suffix}"));
let base_path = url::Url::parse(&target.base_url)
.map_err(|_| invalid())?
.path()
.trim_end_matches('/')
.to_string();
let mut url = url::Url::parse(&target.api_url(suffix)).map_err(|_| invalid())?;
let expected_path = format!("{base_path}/api/w/{}/{suffix}", target.workspace_id);
if suffix.is_empty() || url.path() != expected_path {
return Err(invalid());
}
url.set_query(query);
Ok(url)
}
async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, key, _rest)): Path<(String, String, String)>,
OriginalUri(uri): OriginalUri,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> Result<Response> {
require_own_credentials(&authed)?;
let target = require_target(&db, &w_id).await?;
let url = forwarded_url(&target, uri.path(), uri.query())?;
let stored = load_connection(&db, &w_id, &authed.email, &target)
.await?
.ok_or_else(|| {
Error::BadRequest(format!(
"Connect to {} with your own token before deploying there",
target.base_url
))
})?;
if !constant_time_eq::constant_time_eq(key.as_bytes(), stored.proxy_key.as_bytes()) {
return Err(Error::BadRequest(
"This remote deploy link is not yours or is out of date; reload the page".to_string(),
));
}
// Only what describes the payload: the caller's cookie and token belong to this instance.
let mut request = remote_client(&target)
.await?
.request(method, url)
.bearer_auth(stored.token)
.body(body);
for name in [header::CONTENT_TYPE, header::ACCEPT] {
if let Some(value) = headers.get(&name) {
request = request.header(name, value.clone());
}
}
let response = request.send().await.map_err(|e| unreachable(&target, e))?;
let status = response.status();
// Passed through, a 401 would read as this instance's session having expired and log the
// user out of it.
if status == StatusCode::UNAUTHORIZED {
let body = response.text().await.unwrap_or_default();
return Err(Error::BadGateway(format!(
"{} rejected your stored token: {body}",
target.base_url
)));
}
if status.is_redirection() {
let location = response
.headers()
.get(header::LOCATION)
.and_then(|l| l.to_str().ok())
.unwrap_or_default()
.to_string();
return Err(Error::BadGateway(format!(
"{} redirected to {location}. Set the remote deploy target to the URL it redirects to",
target.base_url
)));
}
// The body is the remote's and may be anything its users stored (an uploaded file, a job
// result typed `text/html`); served from this origin it must never render as a page here.
let mut builder = Response::builder()
.status(status)
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(
header::CONTENT_SECURITY_POLICY,
"sandbox; default-src 'none'",
);
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
builder = builder.header(header::CONTENT_TYPE, content_type);
}
builder
.body(Body::from_stream(response.bytes_stream()))
.map_err(|e| Error::internal_err(format!("building the proxied response: {e}")))
}
/// Move the stored remote tokens to a new workspace key. A token that no longer decrypts is
/// dropped: its owner connects again. A disconnect's emptied row is kept, as the stamp that keeps
/// an older connect from landing.
pub(crate) async fn reencrypt_tokens(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &MagicCrypt256,
new: &MagicCrypt256,
) -> Result<()> {
let rows = sqlx::query!(
"SELECT email, token FROM remote_deploy_token
WHERE workspace_id = $1 AND token <> '' FOR UPDATE",
w_id
)
.fetch_all(&mut *conn)
.await?;
for row in rows {
match decrypt(old, row.token) {
Ok(plain) => {
sqlx::query!(
"UPDATE remote_deploy_token SET token = $1
WHERE workspace_id = $2 AND email = $3",
encrypt(new, &plain),
w_id,
row.email
)
.execute(&mut *conn)
.await?;
}
Err(_) => {
sqlx::query!(
"DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2",
w_id,
row.email
)
.execute(&mut *conn)
.await?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forwarded_url_refuses_paths_the_parser_rewrites() {
let target = RemoteDeployTarget {
base_url: "https://prod.example.com/windmill".to_string(),
workspace_id: "prod".to_string(),
};
let prefix = "/api/w/dev/remote_deploy/proxy/someKey";
let url = forwarded_url(
&target,
&format!("{prefix}/scripts/get/p/f/team/my%20script"),
Some("with_starred_info=true"),
)
.unwrap();
assert_eq!(
url.as_str(),
"https://prod.example.com/windmill/api/w/prod/scripts/get/p/f/team/my%20script?with_starred_info=true"
);
for path in [
format!("{prefix}/../../users/list"),
format!("{prefix}/%2e%2e/%2e%2e/users/list"),
format!("{prefix}/jobs\\run_wait_result\\p/f/team/action"),
prefix.to_string(),
] {
assert!(
forwarded_url(&target, &path, None).is_err(),
"{path} should be refused"
);
}
}
#[test]
fn redacted_uri_masks_the_proxy_key() {
let proxied: axum::http::Uri = "/api/w/dev/remote_deploy/proxy/secretKey/flows/get/f/a?x=1"
.parse()
.unwrap();
assert_eq!(
RedactedUri(&proxied).to_string(),
"/api/w/dev/remote_deploy/proxy/***/flows/get/f/a?x=1"
);
let other: axum::http::Uri = "/api/w/dev/flows/list?x=1".parse().unwrap();
assert_eq!(RedactedUri(&other).to_string(), "/api/w/dev/flows/list?x=1");
}
#[test]
fn target_normalization() {
let target = RemoteDeployTarget {
base_url: " https://prod.example.com/ ".to_string(),
workspace_id: " prod ".to_string(),
}
.normalized()
.unwrap();
assert_eq!(target.base_url, "https://prod.example.com");
assert_eq!(
target.api_url("flows/create"),
"https://prod.example.com/api/w/prod/flows/create"
);
for (base_url, workspace_id) in [
("ftp://prod.example.com", "prod"),
("https://user:pass@prod.example.com", "prod"),
("https://prod.example.com?token=x", "prod"),
("https://prod.example.com", "prod/../admins"),
] {
assert!(
RemoteDeployTarget {
base_url: base_url.to_string(),
workspace_id: workspace_id.to_string(),
}
.normalized()
.is_err(),
"{base_url} {workspace_id} should be refused"
);
}
}
}
@@ -12,6 +12,7 @@ use windmill_api_auth::{
};
use windmill_api_users::users::WorkspaceInvite;
use windmill_common::email_oss::send_email_if_possible;
use windmill_common::ssrf::{validate_webhook_url, webhook_ssrf_error_message};
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
use windmill_common::webhook::WebhookShared;
use windmill_common::{BASE_URL, DB};
@@ -1923,6 +1924,16 @@ async fn edit_webhook(
));
}
// An empty URL is stored as-is and means "no webhook" to the sender.
if let Some(webhook) = ew.webhook.as_deref().filter(|w| !w.is_empty()) {
validate_webhook_url(webhook).await.map_err(|e| {
Error::BadRequest(format!(
"Webhook URL is not allowed: {}",
webhook_ssrf_error_message(&e)
))
})?;
}
let mut tx = db.begin().await?;
if let Some(webhook) = &ew.webhook {
@@ -5960,6 +5971,13 @@ async fn set_encryption_key(
&new_encryption_key,
)
.await?;
crate::remote_deploy::reencrypt_tokens(
&mut tx,
&w_id,
&previous_encryption_key,
&new_encryption_key,
)
.await?;
tx.commit().await?;
@@ -10143,13 +10161,25 @@ async fn leave_workspace(
&format!("u/{}", authed.username),
)
.await?;
sqlx::query!(
let left = sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
&w_id,
&authed.email
)
.execute(&mut *tx)
.await?;
.await?
.rows_affected();
// A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop
// its connection.
if left > 0 {
sqlx::query!(
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
&authed.email,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -841,6 +841,14 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating variable table");
sqlx::query!(
"UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2",
+193 -32
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.815.0
version: 1.817.0
title: Windmill API
contact:
@@ -529,30 +529,6 @@ paths:
responses:
"302":
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
post:
security: []
summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go
operationId: confirmLoginLink
tags:
- user
parameters:
- name: token
in: path
required: true
schema:
type: string
responses:
"200":
description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown
content:
application/json:
schema:
type: object
required:
- location
properties:
location:
type: string
/auth/reset_password:
post:
@@ -1786,6 +1762,26 @@ paths:
schema:
type: string
/settings/instance_ui:
get:
summary: get the instance-wide announcement banner and accent color
description: |
Readable by any authenticated user; every page load reads it. Each value
is the raw stored setting, or null when unset.
operationId: getInstanceUi
tags:
- setting
responses:
"200":
description: the instance_banner and accent_color settings
content:
application/json:
schema:
type: object
properties:
instance_banner: {}
accent_color: {}
/settings_u/ruff_config:
get:
summary: get instance ruff config (unauthenticated)
@@ -4211,6 +4207,100 @@ paths:
deploy_to:
type: string
/w/{workspace}/remote_deploy/target:
get:
summary: get the workspace's deploy target on another instance, and the caller's connection to it
description: |
Once connected, deploy calls aimed at the target go through
`/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`, which forwards any method to
`{base_url}/api/w/{target workspace}/{route}` with the caller's stored token.
operationId: getRemoteDeployTarget
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: remote deploy target and the caller's connection to it
content:
application/json:
schema:
$ref: "#/components/schemas/RemoteDeployStatus"
post:
summary: set or clear the workspace's deploy target on another instance
description: Changing the target drops every token stored for the previous one.
operationId: setRemoteDeployTarget
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
target:
$ref: "#/components/schemas/RemoteDeployTarget"
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/remote_deploy/connect:
post:
summary: store the caller's own token for the remote deploy target
description: |
The token is checked against the target's whoami before it is stored. `target` names the
target the token was obtained for, and the request is refused if the workspace now points
elsewhere, so a token is never sent to an instance it was not meant for.
operationId: connectRemoteDeploy
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
token:
type: string
target:
$ref: "#/components/schemas/RemoteDeployTarget"
required:
- token
- target
responses:
"200":
description: the stored connection
content:
application/json:
schema:
$ref: "#/components/schemas/RemoteDeployConnection"
/w/{workspace}/remote_deploy/disconnect:
post:
summary: forget the caller's token for the remote deploy target
operationId: disconnectRemoteDeploy
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/is_premium:
get:
summary: get if workspace is premium
@@ -6928,7 +7018,7 @@ paths:
type: string
expires_in_s:
type: integer
description: link lifetime in seconds, at most 7200 (default 600)
description: link lifetime in seconds, at most 900 (default 600)
rd:
type: string
description: same-origin path the browser lands on after login (default /user/workspaces)
@@ -6938,12 +7028,6 @@ paths:
mint only while the account still has this login type (for example
pending_oauth), so a link stops working once the owner has set a password
or signed in with a provider
confirm:
type: boolean
description: >-
return a /user/login_link page that signs in only when its button is
clicked, instead of a link spent by opening it; set it for links sent by
email, which mail scanners open on delivery (default false)
responses:
"201":
description: login link minted
@@ -10601,6 +10685,9 @@ paths:
- kind
- tag
- deployment_message
- schema
x-mcp-tool-opaque-fields:
- schema
tags:
- script
parameters:
@@ -10640,6 +10727,9 @@ paths:
- kind
- tag
- deployment_message
- schema
x-mcp-tool-opaque-fields:
- schema
tags:
- script
parameters:
@@ -16220,6 +16310,29 @@ paths:
items:
type: string
/w/{workspace}/jobs/queue/run_now/{id}:
post:
summary: start a queued job scheduled for later now
description: |
Moves the start of a queued job that is waiting for a future time (scheduled, debounced,
re-scheduled by a concurrency limit, or a flow step waiting on a sleep or a retry delay)
to now, keeping the same job id. A job with a
concurrency limit is still checked against its limit when it is pulled. A schedule's
upcoming tick is refused, since the schedule would queue that tick again.
operationId: runQueuedJobNow
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
responses:
"200":
description: job id
content:
text/plain:
schema:
type: string
/w/{workspace}/jobs/queue/cancel_selection:
post:
summary: cancel jobs based on the given uuids
@@ -29658,6 +29771,15 @@ components:
type: string
schema:
type: object
description: >-
JSON Schema of the arguments of `main`, which is what a run form and an
MCP tool offer. Omitted (or `{}`), it is inferred from `content` for
TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible
scripts. For other languages, or code that does not parse, a new script
gets none. A new version of an existing script also keeps what the
previous version's schema says about each argument, or that whole schema
when nothing can be inferred. A dbt script always takes its schema from its
descriptor, whatever is sent.
is_template:
type: boolean
lock:
@@ -31078,6 +31200,7 @@ components:
- "jobs"
- "jobs.cancel"
- "jobs.force_cancel"
- "jobs.run_now"
- "jobs.disapproval"
- "jobs.delete"
- "account.delete"
@@ -36562,6 +36685,44 @@ components:
items:
$ref: "#/components/schemas/GitSyncObjectType"
RemoteDeployTarget:
type: object
properties:
base_url:
type: string
description: root URL of the remote Windmill instance, without /api
workspace_id:
type: string
description: workspace on the remote instance that deploys land in
required:
- base_url
- workspace_id
RemoteDeployConnection:
type: object
properties:
remote_email:
type: string
description: identity the stored token has on the remote instance
proxy_key:
type: string
description: goes in every proxy URL, `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`
connected_at:
type: string
format: date-time
required:
- remote_email
- proxy_key
- connected_at
RemoteDeployStatus:
type: object
properties:
target:
$ref: "#/components/schemas/RemoteDeployTarget"
connection:
$ref: "#/components/schemas/RemoteDeployConnection"
WorkspaceDefaultScripts:
type: object
properties:
+10 -3
View File
@@ -69,7 +69,7 @@ use windmill_common::{
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional,
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
@@ -2506,6 +2506,7 @@ async fn create_app_internal<'a>(
// inside process_app_multipart!, so checking after this call would leave a
// denied app committed in the DB.
check_scopes(&authed, || format!("apps:write:{}", &app.path))?;
check_proper_path(&app.path)?;
validate_frontend_sdk_scopes(&app.policy)?;
if raw_app {
validate_raw_app_path_keys(&app.value.0)?;
@@ -3252,6 +3253,7 @@ async fn create_app_raw_source(
// Before the compile, which costs a job on a worker: it must not run for a
// path already taken, nor for one the caller can't write. `create_app_internal`
// rejects both, but only after the sources have been built.
check_proper_path(&path)?;
if app_exists(&db, &w_id, &path).await? {
return Err(Error::BadRequest(format!("App {path} already exists")));
}
@@ -3462,6 +3464,9 @@ async fn update_app_internal<'a>(
// the token's write scope, not just the source path.
if let Some(npath) = ns.path.as_deref() {
check_scopes(&authed, || format!("apps:write:{}", npath))?;
if npath != path {
check_proper_path(npath)?;
}
}
if raw_app {
@@ -4450,9 +4455,11 @@ async fn execute_component(
// — like `/jobs/run/preview` — confine it to worker tags the caller may use
// (a `if_jobs:filter_tags`-restricted token must not escape its filter).
// `is_preview` implies an authed caller (the guard above returns otherwise).
let push_args = PushArgs { args: &args.args, extra: args.extra };
if is_preview {
if let Some(authed) = opt_authed.as_ref() {
crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, authed).await?;
crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, authed)
.await?;
}
}
// Identity is already resolved to the requesting user in preview mode (the
@@ -4483,7 +4490,7 @@ async fn execute_component(
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
push_args,
&username,
email,
permissioned_as,
+137 -23
View File
@@ -116,7 +116,9 @@ use windmill_common::{
query_builders,
scripts::{ScriptHash, ScriptLang},
users::username_to_permissioned_as,
utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath},
utils::{
not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType, StripPath,
},
};
use windmill_common::{
@@ -304,6 +306,7 @@ pub fn workspaced_service() -> Router {
.route("/queue/position/{timestamp}", get(get_queue_position))
.route("/queue/scheduled_for/{id}", get(get_scheduled_for))
.route("/queue/cancel_selection", post(cancel_selection))
.route("/queue/run_now/{id}", post(run_queued_job_now))
.route("/completed/count", get(count_completed_jobs))
.route("/completed/count_jobs", get(count_completed_jobs_detail))
.route(
@@ -835,6 +838,113 @@ async fn force_cancel(
}
}
/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id.
/// A job with a concurrency limit still goes through the usual check at pull time and is
/// re-scheduled again if its key is still saturated.
async fn run_queued_job_now(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
refuse_upcoming_schedule_tick(&db, &w_id, id).await?;
let mut tx = db.begin().await?;
let previous = sqlx::query_scalar!(
"UPDATE v2_job_queue q SET scheduled_for = now()
FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev
WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL
AND q.scheduled_for > now()
RETURNING prev.scheduled_for",
id,
w_id,
)
.fetch_optional(&mut *tx)
.await?;
let Some(previous) = previous else {
tx.commit().await?;
return Err(
if sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)",
id,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false)
{
Error::BadRequest(format!(
"job {id} is not waiting for a future start: it is running, suspended, \
canceled or already due"
))
} else {
Error::NotFound(format!("queued job id {id} does not exist"))
},
);
};
audit_log(
&mut *tx,
&authed,
"jobs.run_now",
ActionKind::Update,
&w_id,
Some(&id.to_string()),
Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()),
)
.await?;
tx.commit().await?;
windmill_queue::append_logs(
&id,
&w_id,
format!(
"\nStart moved from {previous} to now by {}\n",
authed.display_username()
),
&db.clone().into(),
)
.await;
Ok(id.to_string())
}
/// A schedule queues its next tick when the current one completes, computed from the
/// completion time. Starting a tick that is not yet due would therefore queue that same
/// tick again and run the schedule twice. A tick pushed past its time by a concurrency
/// limit no longer sits on a cron occurrence, so it may still be started early.
async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> {
let Some(tick) = sqlx::query!(
"SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone
FROM v2_job j
JOIN v2_job_queue q USING (id)
JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger
WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'
AND j.parent_job IS NULL AND q.scheduled_for > now()",
id,
w_id,
)
.fetch_optional(db)
.await?
else {
return Ok(());
};
let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?;
let tz =
chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?;
let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz);
if sched.find_next(&just_before)? == tick.scheduled_for {
return Err(Error::BadRequest(format!(
"job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \
directly instead",
tick.path
)));
}
Ok(())
}
#[derive(Serialize)]
struct QueuePosition {
position: Option<i64>,
@@ -7226,7 +7336,10 @@ pub async fn run_workflow_as_code(
) -> error::Result<(StatusCode, String)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &authed).await?;
let mut extra = HashMap::new();
extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint));
let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) };
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?;
check_scopes(&authed, || format!("jobs:run"))?;
if !is_valid_entrypoint_name(&entrypoint) {
@@ -7320,10 +7433,6 @@ pub async fn run_workflow_as_code(
i += 1;
}
let mut extra = HashMap::new();
extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint));
let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) };
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(tag).or(Some(job.tag));
@@ -7360,7 +7469,7 @@ pub async fn run_workflow_as_code(
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
args,
authed.display_username(),
email,
permissioned_as,
@@ -7646,7 +7755,8 @@ pub async fn run_wait_result_job_by_path_get(
.await?;
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let (email, permissioned_as, push_authed, tx) =
if let Some(on_behalf_of) = on_behalf_authed.as_ref() {
@@ -7670,7 +7780,7 @@ pub async fn run_wait_result_job_by_path_get(
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
push_args,
authed.display_username(),
email,
permissioned_as,
@@ -7791,7 +7901,8 @@ pub async fn run_wait_result_script_by_path_internal(
.await?;
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let (email, permissioned_as, push_authed, tx) =
if let Some(on_behalf_of) = on_behalf_of.as_ref() {
@@ -7815,7 +7926,7 @@ pub async fn run_wait_result_script_by_path_internal(
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
push_args,
authed.display_username(),
email,
permissioned_as,
@@ -7904,7 +8015,8 @@ pub async fn run_wait_result_script_by_hash(
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
let tag = run_query.tag.clone().or(tag);
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() {
(
@@ -7940,7 +8052,7 @@ pub async fn run_wait_result_script_by_hash(
&& has_preprocessor.unwrap_or(false),
labels,
},
PushArgs { args: &args.args, extra: args.extra },
push_args,
authed.display_username(),
email,
permissioned_as,
@@ -8465,7 +8577,6 @@ async fn run_preview_script(
require_path_read_access_for_preview(&authed, &preview.path)?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(preview.tag.clone());
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let preview_args = preview.args.unwrap_or_default();
@@ -8484,6 +8595,7 @@ async fn run_preview_script(
}
let extra = if extra.is_empty() { None } else { Some(extra) };
let push_args = PushArgs { extra, args: &preview_args };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let (uuid, tx) = push(
&db,
@@ -8845,7 +8957,6 @@ async fn run_bundle_preview_script(
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(preview.tag.clone());
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let args = preview.args.unwrap_or_default();
@@ -8860,6 +8971,7 @@ async fn run_bundle_preview_script(
m
});
let push_args = PushArgs { extra, args: &args };
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
is_tar = match preview.kind {
Some(PreviewKind::Tarbundle) => true,
@@ -9511,7 +9623,6 @@ async fn run_preview_flow_job(
require_path_read_access_for_preview(&authed, &raw_flow.path)?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(raw_flow.tag.clone());
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false);
@@ -9529,6 +9640,8 @@ async fn run_preview_flow_job(
to_raw_value(temp_script_refs),
);
}
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed)
.await?;
let (uuid, mut tx) = push(
&db,
@@ -9774,11 +9887,6 @@ async fn run_dynamic_select(
}
}
// Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue):
// a caller allowed to read the flow must still be allowed to use its worker tag. No-op for
// inline (tag is None); the script branch checked this inside its helper and returned above.
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
// Invoke the dyn-select entrypoint instead of `main`.
let mut args = request.args.unwrap_or_default();
args.insert(
@@ -9786,6 +9894,11 @@ async fn run_dynamic_select(
serde_json::value::to_raw_value(&request.entrypoint_function)?,
);
// Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue):
// a caller allowed to read the flow must still be allowed to use its worker tag. No-op for
// inline (tag is None); the script branch checked this inside its helper and returned above.
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&args), &authed).await?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
@@ -9920,8 +10033,9 @@ pub async fn run_job_by_hash_inner(
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(tag);
let push_args = PushArgs { args: &args.args, extra: args.extra };
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() {
(
@@ -9957,7 +10071,7 @@ pub async fn run_job_by_hash_inner(
&& has_preprocessor.unwrap_or(false),
labels,
},
PushArgs { args: &args.args, extra: args.extra },
push_args,
authed.display_username(),
email,
permissioned_as,
+9 -1
View File
@@ -534,7 +534,9 @@ pub async fn run_server(
#[cfg(feature = "http_trigger")]
{
let http_killpill_rx = killpill_rx.resubscribe();
triggers::http::refresh_routers_loop(&db, http_killpill_rx).await;
// A worker only serves `/r` when one of its own jobs calls the local API, so it loads the
// routers on that first request rather than at startup.
triggers::http::refresh_routers_loop(&db, http_killpill_rx, server_mode).await;
}
let triggers_service = triggers::generate_trigger_routers();
@@ -731,6 +733,12 @@ pub async fn run_server(
path_autocomplete::workspaced_service(),
)
.nest("/raw_apps", raw_apps::workspaced_service())
.nest(
"/remote_deploy",
windmill_api_workspaces::remote_deploy::workspaced_service(
request_size_limit * 5,
),
)
// CORS so the opaque-origin app iframe can read
// resources/list, resources/type/* with a scoped token.
.nest(
@@ -699,6 +699,10 @@ pub fn all_tools() -> Vec<EndpointTool> {
"content": {
"type": "string"
},
"schema": {
"type": "object",
"description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent."
},
"language": {
"type": "string",
"description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt"
@@ -757,6 +761,10 @@ is, a different one moves it there and archives the old path"),
"content": {
"type": "string"
},
"schema": {
"type": "object",
"description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent."
},
"language": {
"type": "string",
"description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt"
@@ -1024,10 +1032,12 @@ is, a different one moves it there and archives the old path"),
"description": "Detailed documentation for this flow"
},
"value": {
"type": "object"
"type": "object",
"description": "The flow structure containing modules and optional preprocessor/failure handlers"
},
"schema": {
"type": "object"
"type": "object",
"description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"
},
"path": {
"type": "string"
@@ -1080,10 +1090,12 @@ is, a different one moves it there and archives the old path"),
"description": "Detailed documentation for this flow"
},
"value": {
"type": "object"
"type": "object",
"description": "The flow structure containing modules and optional preprocessor/failure handlers"
},
"schema": {
"type": "object"
"type": "object",
"description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"
},
"tag": {
"type": "string"
+6
View File
@@ -386,6 +386,12 @@ impl McpBackend for WindmillBackend {
})
}
async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult<String> {
windmill_common::runnables::runnable_list_fingerprint(&self.db, workspace_id)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
}
fn all_endpoint_tools(&self) -> Vec<EndpointTool> {
all_tools()
}
+29 -1
View File
@@ -5,7 +5,10 @@
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::{db::ApiAuthed, utils::check_scopes};
use crate::{
db::ApiAuthed,
utils::{build_scope_path_filter, check_scopes, ScopePathFilter},
};
use axum::{
body::Body,
extract::{Extension, Json, Path, Query},
@@ -103,6 +106,31 @@ async fn list_apps(
}
}
// In the WHERE, not a retain after the fetch: the result is paginated, and a
// post-fetch filter would return short pages and let a page's size report how
// many raw apps the token may not read. One `?` per term, since a chained `.bind`
// substitutes into a `?` inside the value an earlier one inserted; `starts_with`,
// since LIKE reads a `_` in the path as a wildcard.
if let ScopePathFilter::Restricted { exact, prefix } =
build_scope_path_filter(&authed, "raw_apps", "read")
{
let terms: Vec<String> = exact
.iter()
.chain(&prefix)
.map(|p| "app.path = ?".bind(p))
.chain(
prefix
.iter()
.map(|p| "starts_with(app.path, ?)".bind(&format!("{p}/"))),
)
.collect();
sqlb.and_where(if terms.is_empty() {
"false".to_string()
} else {
format!("({})", terms.join(" OR "))
});
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableApp>(&sql)
+3 -2
View File
@@ -14,6 +14,7 @@ use axum::response::Response as AxumResponse;
use hyper::Response;
use tower_http::trace::{MakeSpan, OnFailure, OnResponse};
use uuid::Uuid;
use windmill_api_workspaces::remote_deploy::RedactedUri;
use windmill_common::log_context::{with_log_context, LogContext};
lazy_static::lazy_static! {
@@ -86,7 +87,7 @@ impl<B> MakeSpan<B> for MyMakeSpan {
tracing::error_span!(
"request",
method = %request.method(),
uri = %request.uri(),
uri = %RedactedUri(request.uri()),
username = field::Empty,
workspace_id = field::Empty,
traceId = tracing_id,
@@ -113,7 +114,7 @@ pub async fn log_context_middleware(request: Request, next: Next) -> AxumRespons
let ctx = LogContext {
method: Some(request.method().to_string()),
uri: Some(request.uri().to_string()),
uri: Some(RedactedUri(request.uri()).to_string()),
trace_id,
..Default::default()
};
@@ -381,7 +381,7 @@ async fn get_http_route_trigger(
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
let routers_cache = if routers_cache.routers.is_empty() {
tracing::warn!("HTTP routers are not loaded, loading from db");
tracing::info!("HTTP routers are not loaded, loading from db");
// refresh_routers takes the write lock, so holding this read guard across it deadlocks.
drop(routers_cache);
let (_, routers_cache) = refresh_routers(db, false).await?;

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