mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
Merge branch 'main' into alexandre/win-2332-add-expiration-to-secret-variable-trigger-runnable-on
Same additive conflicts as the previous merge: main added remote_deploy_target to the workspace_settings column lists, we add variable_expiration_handler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
@@ -549,6 +556,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 +611,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 +667,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 +722,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 +776,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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.816.0"
|
||||
".": "1.817.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+32
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+43
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+6
-3
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
Generated
+134
-127
@@ -588,15 +588,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "astral-tokio-tar"
|
||||
version = "0.5.6"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5"
|
||||
checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"futures-core",
|
||||
"libc",
|
||||
"portable-atomic",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustix 1.1.5",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"xattr",
|
||||
@@ -928,9 +928,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-config"
|
||||
version = "1.8.3"
|
||||
version = "1.8.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0baa720ebadea158c5bda642ac444a2af0cdf7bb66b46d1e4533de5d1f449d0"
|
||||
checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
@@ -938,8 +938,8 @@ dependencies = [
|
||||
"aws-sdk-ssooidc",
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-http 0.63.4",
|
||||
"aws-smithy-json 0.62.4",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1070,15 +1070,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-config"
|
||||
version = "1.68.0"
|
||||
version = "1.101.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7de8bcff88ff2948bf2154204b7eba0ea30698c1c23770c4a917505fb9fcbd5"
|
||||
checksum = "0360cf8f8ab0e328f89b5c60bdcc6203ebce83b8a018c05ed177c4f32f819aff"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-http 0.63.4",
|
||||
"aws-smithy-json 0.62.4",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1086,7 +1087,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand 2.5.0",
|
||||
"http 0.2.12",
|
||||
"once_cell",
|
||||
"http 1.5.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1144,15 +1145,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sqs"
|
||||
version = "1.77.0"
|
||||
version = "1.94.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a6dcd63dffae1f7872df42ce9f1f0f785f8c9a3d3e880ef6c171d0415439df"
|
||||
checksum = "0894909314920ab2c907110e7f91b9f7a6c826bdc830ccf36079c69af2b82335"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-http 0.63.4",
|
||||
"aws-smithy-json 0.62.4",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1160,6 +1162,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand 2.5.0",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1214,15 +1217,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "1.79.0"
|
||||
version = "1.98.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317377afba3498fca4948c5d32b399ef9a5ad35561a1e8a6f2ac7273dabf802d"
|
||||
checksum = "89c4f19655ab0856375e169865c91264de965bd74c407c7f1e403184b1049409"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-http 0.63.4",
|
||||
"aws-smithy-json 0.62.4",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -1231,6 +1235,7 @@ dependencies = [
|
||||
"aws-types",
|
||||
"fastrand 2.5.0",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -2221,7 +2226,7 @@ dependencies = [
|
||||
"memmap2",
|
||||
"num-traits",
|
||||
"num_cpus",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rand_distr",
|
||||
"rayon",
|
||||
"safetensors",
|
||||
@@ -2257,7 +2262,7 @@ dependencies = [
|
||||
"candle-nn",
|
||||
"fancy-regex 0.17.0",
|
||||
"num-traits",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5101,7 +5106,7 @@ checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5"
|
||||
dependencies = [
|
||||
"half",
|
||||
"num-traits",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rand_distr",
|
||||
]
|
||||
|
||||
@@ -5833,7 +5838,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rand_distr",
|
||||
"zerocopy",
|
||||
]
|
||||
@@ -5975,7 +5980,7 @@ dependencies = [
|
||||
"log",
|
||||
"native-tls",
|
||||
"num_cpus",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6002,7 +6007,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"tinyvec",
|
||||
@@ -6024,7 +6029,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"resolv-conf",
|
||||
"serde",
|
||||
"smallvec",
|
||||
@@ -8444,7 +8449,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.28",
|
||||
"ring 0.17.14",
|
||||
"rustls-pemfile 2.2.0",
|
||||
@@ -8813,7 +8818,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"opentelemetry 0.30.0",
|
||||
"percent-encoding",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
@@ -8915,7 +8920,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"pkcs12",
|
||||
"pkcs5",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rc2",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
@@ -9499,7 +9504,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"md-5 0.10.6",
|
||||
"memchr",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"sha2 0.10.9",
|
||||
"stringprep",
|
||||
]
|
||||
@@ -9954,13 +9959,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]]
|
||||
@@ -10044,7 +10048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11109,7 +11113,7 @@ dependencies = [
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"quick-xml",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"url",
|
||||
@@ -13392,7 +13396,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"socket2 0.6.5",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -14084,7 +14088,7 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
|
||||
dependencies = [
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"uuid",
|
||||
"web-time",
|
||||
]
|
||||
@@ -14788,7 +14792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14811,7 +14815,7 @@ dependencies = [
|
||||
"opentelemetry 0.30.0",
|
||||
"opentelemetry_sdk 0.30.0",
|
||||
"prometheus",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.5",
|
||||
"rumqttc",
|
||||
@@ -14876,7 +14880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14910,7 +14914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14923,7 +14927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14971,7 +14975,7 @@ dependencies = [
|
||||
"prometheus",
|
||||
"quick-xml",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"reqwest 0.13.5",
|
||||
"rsa",
|
||||
@@ -15063,7 +15067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15086,7 +15090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15103,7 +15107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15129,7 +15133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15139,7 +15143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15156,7 +15160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15178,7 +15182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15201,7 +15205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15217,7 +15221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15239,7 +15243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15261,7 +15265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15275,7 +15279,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15287,7 +15291,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.5",
|
||||
"rmcp",
|
||||
@@ -15310,7 +15314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15335,7 +15339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15363,7 +15367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15385,7 +15389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15405,7 +15409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15443,7 +15447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15472,7 +15476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15484,7 +15488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15508,7 +15512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15522,10 +15526,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
"constant_time_eq 0.3.1",
|
||||
"futures",
|
||||
"hex",
|
||||
"http 1.5.0",
|
||||
@@ -15533,6 +15538,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"regex",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -15541,6 +15547,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"windmill-api-auth",
|
||||
"windmill-api-jobs",
|
||||
@@ -15557,7 +15564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15571,7 +15578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15590,7 +15597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15650,7 +15657,7 @@ dependencies = [
|
||||
"postgres-native-tls 0.5.3",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"reqwest 0.13.5",
|
||||
"reqwest-middleware",
|
||||
@@ -15697,7 +15704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -15717,7 +15724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15734,7 +15741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15761,7 +15768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15778,7 +15785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15794,7 +15801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15815,7 +15822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15846,7 +15853,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15871,7 +15878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15906,7 +15913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15924,7 +15931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15933,7 +15940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15945,7 +15952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15957,7 +15964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15969,7 +15976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15981,7 +15988,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15993,7 +16000,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16004,7 +16011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16015,7 +16022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16027,7 +16034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16038,7 +16045,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16060,7 +16067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16072,7 +16079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16086,7 +16093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16103,7 +16110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16116,7 +16123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16128,7 +16135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16146,7 +16153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16162,7 +16169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16178,7 +16185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16192,7 +16199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16211,7 +16218,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
@@ -16231,7 +16238,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16271,7 +16278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16282,7 +16289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16317,7 +16324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16341,7 +16348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16351,7 +16358,7 @@ dependencies = [
|
||||
"hyper 1.11.1",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -16374,7 +16381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16401,7 +16408,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16415,7 +16422,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -16434,7 +16441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16454,7 +16461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16488,7 +16495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16524,7 +16531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16547,7 +16554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16571,7 +16578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16595,7 +16602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16610,7 +16617,7 @@ dependencies = [
|
||||
"pg_escape",
|
||||
"postgres-native-tls 0.5.2",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -16630,7 +16637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16658,7 +16665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16683,14 +16690,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
"chrono",
|
||||
"hex",
|
||||
"itertools 0.14.0",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -16702,7 +16709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16755,7 +16762,7 @@ dependencies = [
|
||||
"prometheus",
|
||||
"prost",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.5",
|
||||
@@ -16820,7 +16827,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.816.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.816.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"
|
||||
|
||||
@@ -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 @@
|
||||
029ba12d9e08a376a866acc274bd1e0176c6b353
|
||||
8a1dd52a80d260b43541f9e18a9bb919133a7089
|
||||
|
||||
@@ -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;
|
||||
+113
-68
@@ -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.816.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.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6269,7 +6314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6278,7 +6323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6290,7 +6335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6302,7 +6347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6314,7 +6359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6326,7 +6371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6338,7 +6383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6349,7 +6394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6360,7 +6405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6372,7 +6417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6383,7 +6428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6405,7 +6450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6417,7 +6462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6431,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6448,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6461,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6473,7 +6518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6491,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6507,7 +6552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6523,7 +6568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6555,7 +6600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.816.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6569,14 +6614,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.816.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.816.0"
|
||||
version = "1.817.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -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), variable_expiration_handler(jsonb)
|
||||
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), variable_expiration_handler(jsonb)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
+282
-14
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2563,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,
|
||||
@@ -4015,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(),
|
||||
@@ -4061,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};
|
||||
@@ -1984,6 +1985,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 {
|
||||
@@ -6137,6 +6148,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?;
|
||||
|
||||
@@ -10322,13 +10340,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, variable_expiration_handler) 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, variable_expiration_handler 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, variable_expiration_handler) 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, variable_expiration_handler 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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.816.0
|
||||
version: 1.817.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4189,6 +4189,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
|
||||
@@ -36641,6 +36735,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7226,7 +7226,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 +7323,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 +7359,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 +7645,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 +7670,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 +7791,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 +7816,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 +7905,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 +7942,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 +8467,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 +8485,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 +8847,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 +8861,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 +9513,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 +9530,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 +9777,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 +9784,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 +9923,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 +9961,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,
|
||||
|
||||
@@ -731,6 +731,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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -281,8 +281,29 @@ enum ArchiveImpl {
|
||||
Tar(tokio_tar::Builder<File>),
|
||||
}
|
||||
|
||||
/// Entry paths come from item paths stored in the workspace; an absolute or
|
||||
/// drive-prefixed path, or a parent segment, would make extraction write outside
|
||||
/// the target directory. Win32 strips trailing dots and spaces from a segment,
|
||||
/// so `.. ` resolves to `..` there: any segment made only of those is refused.
|
||||
/// A colon matters only in the first segment (a drive prefix); later segments,
|
||||
/// such as a data table name, may carry one.
|
||||
fn check_archive_entry_path(path: &str) -> Result<()> {
|
||||
let is_dot_segment = |seg: &str| !seg.is_empty() && seg.chars().all(|c| c == '.' || c == ' ');
|
||||
let first = path.split(['/', '\\']).next().unwrap_or_default();
|
||||
if path.starts_with(['/', '\\'])
|
||||
|| first.contains(':')
|
||||
|| path.split(['/', '\\']).any(is_dot_segment)
|
||||
{
|
||||
return Err(Error::internal_err(format!(
|
||||
"refusing to write archive entry with path traversal: {path}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ArchiveImpl {
|
||||
async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> {
|
||||
check_archive_entry_path(path)?;
|
||||
match self {
|
||||
ArchiveImpl::Tar(t) => {
|
||||
let bytes = content.as_bytes();
|
||||
@@ -1427,9 +1448,17 @@ pub(crate) async fn tarball_workspace(
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
for service_name in ServiceName::iter() {
|
||||
let native_triggers =
|
||||
list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None)
|
||||
.await?;
|
||||
let native_triggers = list_native_triggers(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
service_name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
windmill_api_auth::ScopePathFilter::AllowAll,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Native triggers (Nextcloud, Google Drive, GitHub) are never
|
||||
// cloned into a fork — a fork only has one if its owner created
|
||||
@@ -1643,7 +1672,9 @@ pub(crate) async fn tarball_workspace(
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color.clone(),
|
||||
operator_settings: row.operator_settings.clone(),
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable.clone()),
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(
|
||||
row.datatable.clone(),
|
||||
),
|
||||
slack_team_id: row.slack_team_id.clone(),
|
||||
slack_name: row.slack_name.clone(),
|
||||
slack_command_script: row.slack_command_script.clone(),
|
||||
@@ -1788,6 +1819,41 @@ pub(crate) async fn tarball_workspace(
|
||||
Ok((headers, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod archive_entry_path_tests {
|
||||
use super::check_archive_entry_path;
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_absolute_paths() {
|
||||
for ok in [
|
||||
"u/admin/app.app.json",
|
||||
"f/x/a..b.script.json",
|
||||
"settings.yaml",
|
||||
"migrations/datatable/foo:bar/20260617120000_name.up.sql",
|
||||
] {
|
||||
assert!(
|
||||
check_archive_entry_path(ok).is_ok(),
|
||||
"{ok} should be accepted"
|
||||
);
|
||||
}
|
||||
for bad in [
|
||||
"f/x/../../evil.app.json",
|
||||
"../evil",
|
||||
"/etc/evil",
|
||||
"f\\..\\evil",
|
||||
"f/x/.. /.. /evil.app.json",
|
||||
"f/x/.../evil",
|
||||
"C:/evil",
|
||||
"\\evil",
|
||||
] {
|
||||
assert!(
|
||||
check_archive_entry_path(bad).is_err(),
|
||||
"{bad} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fork_export_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -20,7 +20,8 @@ use crate::{
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`). System-token labels are load-bearing —
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`, `remote-deploy:*`).
|
||||
/// System-token labels are load-bearing —
|
||||
/// session cleanup, super_admin propagation, expiry notifications and username overrides
|
||||
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
|
||||
/// a user token.
|
||||
@@ -48,6 +49,7 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with("impersonation:")
|
||||
&& !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with(REMOTE_DEPLOY_TOKEN_LABEL_PREFIX)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +75,14 @@ pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
|
||||
/// `/users/tokens/create`.
|
||||
pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:";
|
||||
|
||||
/// Label prefix, followed by the host of the instance deploying with it, of the token the
|
||||
/// `/user/remote_deploy_authorize` page mints for another Windmill instance. Reserved in
|
||||
/// [`is_user_token`], whose SQL and frontend mirrors spell it out: it is renewed by connecting
|
||||
/// again from the deploying instance, which reports this one rejecting it, so an expiry email or
|
||||
/// alert here points at the wrong place. Not in [`is_server_minted_label`], since the page mints it
|
||||
/// through `/users/tokens/create`.
|
||||
pub const REMOTE_DEPLOY_TOKEN_LABEL_PREFIX: &str = "remote-deploy:";
|
||||
|
||||
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
|
||||
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
|
||||
@@ -997,6 +1007,7 @@ mod tests {
|
||||
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
|
||||
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
|
||||
assert!(!is_user_token(Some("cli-login:admin")));
|
||||
assert!(!is_user_token(Some("remote-deploy:windmill.example.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1029,9 +1029,10 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[
|
||||
),
|
||||
(
|
||||
"object_store_cache_config",
|
||||
&["secret_key", "serviceAccountKey"],
|
||||
&["secret_key", "serviceAccountKey", "accessKey"],
|
||||
),
|
||||
("custom_instance_pg_databases", &["user_pwd"]),
|
||||
("github_enterprise_app", &["private_key"]),
|
||||
];
|
||||
|
||||
fn redact_json_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
@@ -2647,6 +2648,26 @@ mod tests {
|
||||
assert!(formatted.contains("****"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_setting_value_redacts_nested_credentials() {
|
||||
let val = serde_json::json!({
|
||||
"type": "Azure",
|
||||
"accountName": "acct",
|
||||
"containerName": "c",
|
||||
"accessKey": "azure-storage-account-key-12345"
|
||||
});
|
||||
let formatted = format_setting_value("object_store_cache_config", &val);
|
||||
assert!(!formatted.contains("azure-storage-account-key-12345"));
|
||||
assert!(formatted.contains("acct"));
|
||||
|
||||
let val = serde_json::json!({
|
||||
"app_id": 1,
|
||||
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretbody\n-----END RSA PRIVATE KEY-----"
|
||||
});
|
||||
let formatted = format_setting_value("github_enterprise_app", &val);
|
||||
assert!(!formatted.contains("MIIEsecretbody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "literal_value() called on unresolved secret ref")]
|
||||
fn string_or_secret_ref_literal_value_panics_on_ref() {
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::{
|
||||
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
|
||||
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
|
||||
utils::{StripPath, HTTP_CLIENT},
|
||||
worker::{to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR},
|
||||
worker::{custom_tag_matches, to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR},
|
||||
workspaces::workspace_with_fork_ancestors,
|
||||
FlowVersionInfo, ScriptHashInfo, Tag,
|
||||
};
|
||||
@@ -337,33 +337,70 @@ lazy_static::lazy_static! {
|
||||
// `is_super_admin` is passed in (not derived from an email here) so callers can
|
||||
// make it job-token-aware: a job's WM_TOKEN must never count as superadmin
|
||||
// (GHSA-hfh4-cx4h-3fcr). See `is_super_admin_authed` at the request wrapper.
|
||||
//
|
||||
// Custom tags judge `resolved_tag`, the queue the job actually lands on, and `tag` only as the
|
||||
// job's author wrote it: a dynamic tag names any queue its values do. Callers go through
|
||||
// `windmill_queue::check_tag_available_for_push`, which resolves it the way `push` does.
|
||||
// `resolved_tag` is `None` when the values are not known yet: only an entry spelled like `tag`
|
||||
// admits it then. `tag_workspace` resolves to what the job's `$workspace` stands for.
|
||||
pub async fn check_tag_available_for_workspace_internal(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
tag: &str,
|
||||
resolved_tag: Option<&str>,
|
||||
tag_workspace: impl Future<Output = String>,
|
||||
is_super_admin: bool,
|
||||
scope_tags: Option<Vec<&str>>,
|
||||
) -> error::Result<()> {
|
||||
let mut is_tag_in_scope_tags = None;
|
||||
let mut is_tag_in_workspace_custom_tags = false;
|
||||
|
||||
if let Some(scope_tags) = scope_tags.as_ref() {
|
||||
is_tag_in_scope_tags = Some(scope_tags.contains(&tag));
|
||||
}
|
||||
let is_tag_in_scope_tags = scope_tags.as_ref().map(|scope_tags| {
|
||||
scope_tags.contains(&tag) || resolved_tag.is_some_and(|r| scope_tags.contains(&r))
|
||||
});
|
||||
|
||||
let custom_tags_per_w = CUSTOM_TAGS_PER_WORKSPACE.load();
|
||||
if custom_tags_per_w.global.contains(&tag.to_string()) {
|
||||
is_tag_in_workspace_custom_tags = true;
|
||||
} else if let Some(specific_tag) = custom_tags_per_w.specific.get(tag) {
|
||||
// Only a fork-scoped tag can match through the lineage, so every other tag keeps the
|
||||
// ancestor lookup off the push path entirely.
|
||||
let chain = if specific_tag.is_fork_scoped() {
|
||||
workspace_with_fork_ancestors(db, w_id).await?
|
||||
} else {
|
||||
vec![w_id.to_string()]
|
||||
};
|
||||
is_tag_in_workspace_custom_tags = specific_tag.applies_to_workspace(&chain);
|
||||
}
|
||||
// Only an entry reading `$workspace` needs it, so every other tag keeps its lookup off the
|
||||
// push path.
|
||||
let tag_workspace = if resolved_tag.is_some()
|
||||
&& custom_tags_per_w
|
||||
.global
|
||||
.iter()
|
||||
.chain(custom_tags_per_w.specific.keys())
|
||||
.any(|t| t.contains("$workspace"))
|
||||
{
|
||||
tag_workspace.await
|
||||
} else {
|
||||
w_id.to_string()
|
||||
};
|
||||
// A job whose tag is written exactly as an entry resolves inside what that entry admits, which
|
||||
// is the only way in for an entry `custom_tag_matches` cannot turn into a pattern.
|
||||
let admits = |entry: &str| {
|
||||
entry == tag || resolved_tag.is_some_and(|r| custom_tag_matches(entry, r, &tag_workspace))
|
||||
};
|
||||
let scoped = custom_tags_per_w
|
||||
.specific
|
||||
.iter()
|
||||
.filter(|(name, _)| admits(name))
|
||||
.collect::<Vec<_>>();
|
||||
// Only a fork-scoped tag can match through the lineage, so every other tag keeps the
|
||||
// ancestor lookup off the push path entirely.
|
||||
let chain = if scoped.iter().any(|(_, t)| t.is_fork_scoped()) {
|
||||
workspace_with_fork_ancestors(db, w_id).await?
|
||||
} else {
|
||||
vec![w_id.to_string()]
|
||||
};
|
||||
// A tag listed by its own name with a scope stays inside that scope: a pattern it fits does
|
||||
// not open it to other workspaces. Only listing it by name without a scope does.
|
||||
let lands_on = resolved_tag.unwrap_or(tag);
|
||||
let confined_by = custom_tags_per_w
|
||||
.specific
|
||||
.get_key_value(lands_on)
|
||||
.filter(|(name, t)| {
|
||||
!name.contains('$')
|
||||
&& !t.applies_to_workspace(&chain)
|
||||
&& !custom_tags_per_w.global.iter().any(|e| e == lands_on)
|
||||
});
|
||||
let is_tag_in_workspace_custom_tags = confined_by.is_none()
|
||||
&& (custom_tags_per_w.global.iter().any(|entry| admits(entry))
|
||||
|| scoped.iter().any(|(_, t)| t.applies_to_workspace(&chain)));
|
||||
|
||||
match is_tag_in_scope_tags {
|
||||
Some(true) | None => {
|
||||
@@ -375,6 +412,12 @@ pub async fn check_tag_available_for_workspace_internal(
|
||||
}
|
||||
|
||||
if !is_super_admin {
|
||||
let tag = match resolved_tag {
|
||||
Some(resolved_tag) if resolved_tag != tag => {
|
||||
format!("{tag} (resolved to {resolved_tag})")
|
||||
}
|
||||
_ => tag.to_string(),
|
||||
};
|
||||
if scope_tags.is_some() && is_tag_in_scope_tags.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Tag {tag} is not available in your scope"
|
||||
@@ -383,9 +426,35 @@ pub async fn check_tag_available_for_workspace_internal(
|
||||
|
||||
if *TAGS_ARE_SENSITIVE {
|
||||
return Err(Error::BadRequest(format!("{tag} is not available to you")));
|
||||
} else if let Some((name, confining)) = confined_by {
|
||||
let overridden = custom_tags_per_w
|
||||
.global
|
||||
.iter()
|
||||
.filter(|entry| admits(entry))
|
||||
.cloned()
|
||||
.chain(
|
||||
scoped
|
||||
.iter()
|
||||
.filter(|(_, t)| t.applies_to_workspace(&chain))
|
||||
.map(|(n, t)| t.authored(n)),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let overridden = if overridden.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
", which takes precedence over the custom tags it also fits: {}",
|
||||
overridden.join(", ")
|
||||
)
|
||||
};
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Tag {tag} is not available in workspace {w_id}: the custom tag {} restricts it \
|
||||
to other workspaces{overridden}",
|
||||
confining.authored(name)
|
||||
)));
|
||||
} else {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Only super admins are allowed to use tags that are not included in the allowed CUSTOM_TAGS: {:?}",
|
||||
"Tag {tag} is not included in the allowed CUSTOM_TAGS, which only super admins can go beyond: {:?}",
|
||||
custom_tags_per_w
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ pub fn try_expand_internal_db_query(
|
||||
// Metadata queries
|
||||
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
|
||||
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"ALL_FOREIGN_KEYS" => expand_all_foreign_keys(db_type).map(ExpandedQuery::sql),
|
||||
"PRIMARY_KEY_CONSTRAINT" => {
|
||||
expand_primary_key_constraint(json_str, db_type).map(ExpandedQuery::sql)
|
||||
}
|
||||
@@ -2737,6 +2738,47 @@ fn expand_foreign_keys(json_str: &str, db_type: DbType) -> Result<String, String
|
||||
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
||||
}
|
||||
|
||||
fn expand_all_foreign_keys(db_type: DbType) -> Result<String, String> {
|
||||
make_all_foreign_keys_query(db_type)
|
||||
}
|
||||
|
||||
/// Every foreign key of the database, one row per referencing column.
|
||||
///
|
||||
/// Read from `pg_constraint` rather than `information_schema`: the latter's
|
||||
/// `constraint_column_usage` loses the pairing of a composite key's columns,
|
||||
/// which the diagram needs to line a source column up with the column it points
|
||||
/// at. `unnest(conkey, confkey)` keeps them zipped in declaration order.
|
||||
fn make_all_foreign_keys_query(db_type: DbType) -> Result<String, String> {
|
||||
if db_type != DbType::Postgresql {
|
||||
return Err(format!(
|
||||
"The schema diagram is only supported for PostgreSQL, not {:?}",
|
||||
db_type
|
||||
));
|
||||
}
|
||||
Ok(String::from(
|
||||
"SELECT
|
||||
con.conname as fk_constraint_name,
|
||||
src_ns.nspname as source_schema,
|
||||
src.relname as source_table,
|
||||
src_att.attname as source_column,
|
||||
tgt_ns.nspname as target_schema,
|
||||
tgt.relname as target_table,
|
||||
tgt_att.attname as target_column,
|
||||
k.ord as ordinal
|
||||
FROM pg_catalog.pg_constraint con
|
||||
JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
|
||||
JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
|
||||
JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
|
||||
JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
|
||||
JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(src_attnum, tgt_attnum, ord) ON true
|
||||
JOIN pg_catalog.pg_attribute src_att ON src_att.attrelid = src.oid AND src_att.attnum = k.src_attnum
|
||||
JOIN pg_catalog.pg_attribute tgt_att ON tgt_att.attrelid = tgt.oid AND tgt_att.attnum = k.tgt_attnum
|
||||
WHERE con.contype = 'f'
|
||||
AND src_ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY src_ns.nspname, src.relname, con.conname, k.ord;",
|
||||
))
|
||||
}
|
||||
|
||||
fn expand_primary_key_constraint(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: PrimaryKeyConstraintPayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid PRIMARY_KEY_CONSTRAINT payload: {}", e))?;
|
||||
@@ -4721,6 +4763,26 @@ mod tests {
|
||||
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ALL_FOREIGN_KEYS
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_expand_all_foreign_keys_postgresql() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
||||
assert!(sql.contains("con.contype = 'f'"));
|
||||
// Columns of a composite key must stay zipped with the ones they point at.
|
||||
assert!(sql.contains("unnest(con.conkey, con.confkey) WITH ORDINALITY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_all_foreign_keys_rejects_non_postgres() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#;
|
||||
let result = try_expand_internal_db_query(marker, &ScriptLang::Mysql);
|
||||
assert!(result.unwrap().is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PRIMARY_KEY_CONSTRAINT
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,8 @@ pub const ALLOW_PRIVATE_SAML_METADATA_URLS_ENV: &str = "ALLOW_PRIVATE_SAML_METAD
|
||||
|
||||
pub const ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV: &str = "ALLOW_PRIVATE_GUEST_JWKS_URLS";
|
||||
|
||||
pub const ALLOW_PRIVATE_WEBHOOK_URLS_ENV: &str = "ALLOW_PRIVATE_WEBHOOK_URLS";
|
||||
|
||||
/// Lets every git call reach hosts on a private network, whoever it is made for.
|
||||
/// Without it, [`private_git_host_allowed`] decides.
|
||||
pub const ALLOW_LOCAL_GIT_REMOTES_ENV: &str = "ALLOW_LOCAL_GIT_REMOTES";
|
||||
@@ -207,6 +209,12 @@ pub fn allow_private_saml_metadata_urls() -> bool {
|
||||
.is_some_and(|v| v == "true" || v == "1")
|
||||
}
|
||||
|
||||
fn allow_private_webhook_urls() -> bool {
|
||||
std::env::var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV)
|
||||
.ok()
|
||||
.is_some_and(|v| v == "true" || v == "1")
|
||||
}
|
||||
|
||||
fn allow_local_git_remotes() -> bool {
|
||||
std::env::var(ALLOW_LOCAL_GIT_REMOTES_ENV)
|
||||
.ok()
|
||||
@@ -321,6 +329,26 @@ pub async fn validate_mcp_server_url(url: &str) -> Result<ValidatedTarget, SsrfV
|
||||
validate_url_for_ssrf(url).await
|
||||
}
|
||||
|
||||
/// Save-time check only: the webhook is sent later from another task that resolves
|
||||
/// the host again, so the returned target cannot be pinned onto that connect.
|
||||
pub async fn validate_webhook_url(url: &str) -> Result<ValidatedTarget, SsrfValidationError> {
|
||||
let parsed =
|
||||
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
|
||||
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())),
|
||||
}
|
||||
|
||||
let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?;
|
||||
|
||||
if allow_private_webhook_urls() {
|
||||
return Ok(ValidatedTarget::unpinned(host));
|
||||
}
|
||||
|
||||
validate_url_for_ssrf(url).await
|
||||
}
|
||||
|
||||
/// Validate an MCP-related URL and return the [`ValidatedTarget`] so the caller
|
||||
/// can pin the connect: the OAuth registration/discovery/token requests carry
|
||||
/// secrets, so they must target the validated address (see
|
||||
@@ -358,6 +386,16 @@ pub fn saml_ssrf_error_message(e: &SsrfValidationError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_ssrf_error_message(e: &SsrfValidationError) -> String {
|
||||
match e {
|
||||
SsrfValidationError::Private { .. } => format!(
|
||||
"{e}. If you need to use private/internal webhook URLs, \
|
||||
set the {ALLOW_PRIVATE_WEBHOOK_URLS_ENV}=true environment variable"
|
||||
),
|
||||
_ => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ipv4) => is_private_ipv4(ipv4),
|
||||
@@ -684,6 +722,71 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
struct PrivateWebhookUrlsEnvGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl PrivateWebhookUrlsEnvGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let previous = std::env::var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV).ok();
|
||||
match value {
|
||||
Some(value) => std::env::set_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV, value),
|
||||
None => std::env::remove_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV),
|
||||
}
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrivateWebhookUrlsEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV, value),
|
||||
None => std::env::remove_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_webhook_url_blocks_private_by_default_with_env_hint() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateWebhookUrlsEnvGuard::set(None);
|
||||
|
||||
let private_error = validate_webhook_url("http://127.0.0.1/hook")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
private_error,
|
||||
SsrfValidationError::Private { resolved: false }
|
||||
));
|
||||
assert!(
|
||||
webhook_ssrf_error_message(&private_error).contains("ALLOW_PRIVATE_WEBHOOK_URLS=true")
|
||||
);
|
||||
|
||||
let invalid_error = validate_webhook_url("ftp://example.com/hook")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
!webhook_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_WEBHOOK_URLS_ENV)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_webhook_url_allows_private_when_env_is_set_but_keeps_syntax_guards() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateWebhookUrlsEnvGuard::set(Some("true"));
|
||||
|
||||
assert!(validate_webhook_url("http://127.0.0.1/hook").await.is_ok());
|
||||
assert!(validate_webhook_url("http://10.0.0.1/hook").await.is_ok());
|
||||
assert!(matches!(
|
||||
validate_webhook_url("file:///etc/passwd").await,
|
||||
Err(SsrfValidationError::DisallowedScheme(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_webhook_url("not-a-url").await,
|
||||
Err(SsrfValidationError::InvalidUrl(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_git_hosts_are_refused_to_non_admins_and_on_cloud() {
|
||||
use GitRemoteCaller::{AdminOrSystem, NonAdmin};
|
||||
|
||||
@@ -245,6 +245,9 @@ lazy_static::lazy_static! {
|
||||
/// Reject a path the `proper_id` constraint would reject anyway, so the caller
|
||||
/// gets a plain 400 instead of the raw Postgres constraint-violation string,
|
||||
/// which names the table and constraint and echoes the input back.
|
||||
///
|
||||
/// `app` and the trigger tables have no such constraint: for them this check is
|
||||
/// the only authority, so loosening it lets malformed paths be stored.
|
||||
pub fn check_proper_path(path: &str) -> Result<()> {
|
||||
// The column is varchar(255); without this an over-long but well-formed path
|
||||
// still reaches Postgres and leaks the same kind of message back.
|
||||
|
||||
@@ -164,7 +164,10 @@ impl WebhookShared {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
// TODO: investigate pool timeouts and such if TCP load is high
|
||||
.timeout(Duration::from_secs(5)),
|
||||
.timeout(Duration::from_secs(5))
|
||||
// The workspace webhook URL is SSRF-checked when saved; a redirect
|
||||
// would send the POST past that check.
|
||||
.redirect(reqwest::redirect::Policy::none()),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
@@ -41,6 +41,8 @@ use crate::{
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct CustomTags {
|
||||
pub global: Vec<String>,
|
||||
/// Keyed by the entry's name, which may hold placeholders like a global entry: only a name
|
||||
/// without any can be looked up by the tag it admits, the others through [`custom_tag_matches`].
|
||||
pub specific: HashMap<String, SpecificTagData>,
|
||||
}
|
||||
|
||||
@@ -83,20 +85,7 @@ impl CustomTags {
|
||||
} else {
|
||||
self.specific
|
||||
.iter()
|
||||
.map(|(tag, tag_data)| {
|
||||
let separator = tag_data.tag_type.corresponding_separator();
|
||||
let mut workspaces = tag_data
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|w| w.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(&*separator.to_string());
|
||||
if tag_data.tag_type == SpecificTagType::AllExcluding {
|
||||
// the AllExcluding tag syntax has a leading separator
|
||||
workspaces.insert(0, separator);
|
||||
}
|
||||
format!("{}({})", tag, workspaces)
|
||||
})
|
||||
.map(|(tag, tag_data)| tag_data.authored(tag))
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
let all_tags = self.global.clone();
|
||||
@@ -104,6 +93,84 @@ impl CustomTags {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a job whose tag resolved to `tag` falls under the custom tag `entry`. An entry holding
|
||||
/// placeholders is a pattern over resolved tags: `$args[...]` and `$flow_expr[...]` match any
|
||||
/// text, since whoever pushes the job picks their values, and `$workspace` matches
|
||||
/// `tag_workspace`, what the job's own `$workspace` resolves to. The text around them must match
|
||||
/// as written: it is what confines `gpu-$args[size]` to the `gpu-` tags.
|
||||
///
|
||||
/// Placeholders whose values are tied, a repeat or one reading inside another, make an entry no
|
||||
/// wildcard pattern describes (`t-$args[id]-$args[id]` never resolves to `t-a-b`). Such an entry
|
||||
/// matches nothing here: it admits only a job whose tag is written exactly as the entry is.
|
||||
pub fn custom_tag_matches(entry: &str, tag: &str, tag_workspace: &str) -> bool {
|
||||
if !entry.contains('$') {
|
||||
return entry == tag;
|
||||
}
|
||||
let dynamic: Vec<&str> = CUSTOM_TAG_PLACEHOLDER
|
||||
.find_iter(entry)
|
||||
.map(|m| m.as_str())
|
||||
.filter(|p| *p != "$workspace")
|
||||
.collect();
|
||||
let tied = dynamic
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, a)| dynamic[i + 1..].iter().any(|b| placeholders_tied(a, b)));
|
||||
if tied {
|
||||
return false;
|
||||
}
|
||||
// The literal runs between wildcards, with `$workspace` substituted.
|
||||
let mut pieces = vec![];
|
||||
let mut current = String::new();
|
||||
let mut last_end = 0;
|
||||
for m in CUSTOM_TAG_PLACEHOLDER.find_iter(entry) {
|
||||
current.push_str(&entry[last_end..m.start()]);
|
||||
if m.as_str() == "$workspace" {
|
||||
current.push_str(tag_workspace);
|
||||
} else {
|
||||
pieces.push(std::mem::take(&mut current));
|
||||
}
|
||||
last_end = m.end();
|
||||
}
|
||||
current.push_str(&entry[last_end..]);
|
||||
pieces.push(current);
|
||||
|
||||
let [first, rest @ ..] = pieces.as_slice() else {
|
||||
return false;
|
||||
};
|
||||
let Some((last, middle)) = rest.split_last() else {
|
||||
return tag == first;
|
||||
};
|
||||
let Some(mut inner) = tag
|
||||
.strip_prefix(first.as_str())
|
||||
.and_then(|t| t.strip_suffix(last.as_str()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
for piece in middle {
|
||||
match inner.find(piece.as_str()) {
|
||||
Some(i) => inner = &inner[i + piece.len()..],
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether two `$args[...]` / `$flow_expr[...]` placeholders read the same value, or one reads a
|
||||
/// value inside the other's.
|
||||
fn placeholders_tied(a: &str, b: &str) -> bool {
|
||||
let (Some((kind_a, path_a)), Some((kind_b, path_b))) = (a.split_once('['), b.split_once('['))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (path_a, path_b) = (path_a.trim_end_matches(']'), path_b.trim_end_matches(']'));
|
||||
let inside = |outer: &str, inner: &str| {
|
||||
inner
|
||||
.strip_prefix(outer)
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
};
|
||||
kind_a == kind_b && (path_a == path_b || inside(path_a, path_b) || inside(path_b, path_a))
|
||||
}
|
||||
|
||||
/// Marker suffixed to a workspace id inside a custom tag's scope (`mytag(prod*)`) to extend the
|
||||
/// entry to that workspace's forks. `*` cannot appear in a workspace id (the `proper_id` check
|
||||
/// constraint restricts them to `^\w+(-\w+)*$`), so it can never collide with a real id.
|
||||
@@ -183,6 +250,22 @@ impl SpecificTagData {
|
||||
pub fn is_fork_scoped(&self) -> bool {
|
||||
self.workspaces.iter().any(|w| w.include_forks)
|
||||
}
|
||||
|
||||
/// The entry named `name` with this scope, as written in the custom tags: `tag(ws1+ws2)`.
|
||||
pub fn authored(&self, name: &str) -> String {
|
||||
let separator = self.tag_type.corresponding_separator();
|
||||
let mut workspaces = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|w| w.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(&*separator.to_string());
|
||||
if self.tag_type == SpecificTagType::AllExcluding {
|
||||
// the AllExcluding tag syntax has a leading separator
|
||||
workspaces.insert(0, separator);
|
||||
}
|
||||
format!("{}({})", name, workspaces)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SpecificTagType {
|
||||
@@ -464,7 +547,9 @@ lazy_static::lazy_static! {
|
||||
|
||||
|
||||
|
||||
// ^([\w-]+) # Group 1: tag name
|
||||
// ^( # Group 1: tag name, a pattern when it holds placeholders
|
||||
// (?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+
|
||||
// )
|
||||
// \( # Literal '('
|
||||
// ( # Group 2: the full workspace list
|
||||
// (?:[\w-]+\*?\+)*[\w-]+\*? # NoneExcept pattern: ws1+ws2*
|
||||
@@ -473,8 +558,12 @@ lazy_static::lazy_static! {
|
||||
// )
|
||||
// \)$ # Closing ')'
|
||||
//
|
||||
// The optional `*` after each workspace id is the fork marker, see [`WorkspaceMatcher`].
|
||||
static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^([\w-]+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap();
|
||||
// The placeholders are CUSTOM_TAG_PLACEHOLDER's. The optional `*` after each workspace id is
|
||||
// the fork marker, see [`WorkspaceMatcher`].
|
||||
static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^((?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap();
|
||||
|
||||
// The placeholders a job's tag is resolved from when it is pushed, see [`custom_tag_matches`].
|
||||
static ref CUSTOM_TAG_PLACEHOLDER: Regex = Regex::new(r"\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\]").unwrap();
|
||||
|
||||
pub static ref DISABLE_BUNDLING: bool = std::env::var("DISABLE_BUNDLING")
|
||||
.ok()
|
||||
@@ -3070,6 +3159,92 @@ mod tests {
|
||||
assert_eq!(result, vec!["foo", "legacy(^ws1^ws2)", "urgent(ws1+ws2)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_tag_matches_resolved_tags() {
|
||||
let matches = |entry, tag| custom_tag_matches(entry, tag, "ws1");
|
||||
|
||||
assert!(matches("gpu", "gpu"));
|
||||
assert!(!matches("gpu", "gpu-large"));
|
||||
|
||||
// The text around a placeholder fences what its value can make of the tag.
|
||||
assert!(matches("gpu-$args[size]", "gpu-large"));
|
||||
assert!(matches("gpu-$flow_expr[results.a.size]", "gpu-"));
|
||||
assert!(!matches("gpu-$args[size]", "prod"));
|
||||
assert!(!matches("gpu-$args[size]", "xgpu-large"));
|
||||
assert!(matches("$args[region]-gpu", "eu-gpu"));
|
||||
assert!(!matches("$args[region]-gpu", "eu-gpu-x"));
|
||||
assert!(matches("a-$args[x]-b-$args[y]-c", "a-1-b-2-c"));
|
||||
assert!(!matches("a-$args[x]-b-$args[y]-c", "a-1-c"));
|
||||
assert!(!matches("ab$args[x]ba", "aba"));
|
||||
|
||||
// A bare placeholder admits every tag.
|
||||
assert!(matches("$flow_expr[results.a.tag]", "anything"));
|
||||
|
||||
// Tied placeholders are no pattern: their entry admits only its own text.
|
||||
assert!(!matches("t-$args[id]-$args[id]", "t-a-a"));
|
||||
assert!(!matches("t-$args[a]-$args[a.b]", "t-x-y"));
|
||||
assert!(matches("t-$args[a.x]-$args[a.y]", "t-x-y"));
|
||||
assert!(matches("t-$args[id]-$flow_expr[flow_input.id]", "t-x-y"));
|
||||
|
||||
// `$workspace` stands for the job's own workspace only.
|
||||
assert!(matches("tag-$workspace", "tag-ws1"));
|
||||
assert!(!matches("tag-$workspace", "tag-ws2"));
|
||||
assert!(matches("$workspace-$args[size]", "ws1-large"));
|
||||
assert!(!matches("$workspace-$args[size]", "ws2-large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scoped_custom_tag_patterns_parse_and_round_trip() {
|
||||
let input = vec".to_string(),
|
||||
"cpu-$flow_expr[results.a.size](^ws1)".to_string(),
|
||||
"$workspace-$args[x](prod*)".to_string(),
|
||||
"t-$args[id]-$args[id](ws1)".to_string(),
|
||||
];
|
||||
let tags = CustomTags::from(input.clone());
|
||||
|
||||
assert!(tags.global.is_empty());
|
||||
assert_eq!(
|
||||
tags.specific["gpu-$args[size]"],
|
||||
SpecificTagData {
|
||||
tag_type: SpecificTagType::NoneExcept,
|
||||
workspaces: vec![matcher("ws1"), matcher("ws2")],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tags.specific["cpu-$flow_expr[results.a.size]"],
|
||||
SpecificTagData {
|
||||
tag_type: SpecificTagType::AllExcluding,
|
||||
workspaces: vec![matcher("ws1")],
|
||||
}
|
||||
);
|
||||
assert!(tags.specific["$workspace-$args[x]"].is_fork_scoped());
|
||||
assert!(tags.specific.contains_key("t-$args[id]-$args[id]"));
|
||||
|
||||
let mut result = tags.to_string_vec(None);
|
||||
result.sort();
|
||||
let mut expected = input;
|
||||
expected.sort();
|
||||
assert_eq!(result, expected);
|
||||
|
||||
let mut for_ws1 = tags.to_string_vec(Some(&chain(&["ws1"])));
|
||||
for_ws1.sort();
|
||||
assert_eq!(for_ws1, vec!["gpu-$args[size]", "t-$args[id]-$args[id]"]);
|
||||
|
||||
// Only the placeholders a tag resolves from make a name a pattern: anything else is no
|
||||
// scoped entry at all.
|
||||
for literal in [
|
||||
"gpu-$foo(ws1)",
|
||||
"gpu-$args[](ws1)",
|
||||
"gpu-$args[a.](ws1)",
|
||||
"gpu.x(ws1)",
|
||||
] {
|
||||
let tags = CustomTags::from(vec![literal.to_string()]);
|
||||
assert_eq!(tags.global, vec![literal]);
|
||||
assert!(tags.specific.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fork_marker_parses_and_round_trips() {
|
||||
let tags = CustomTags::from(vec![
|
||||
|
||||
@@ -16,7 +16,8 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgConnection;
|
||||
use std::sync::Arc;
|
||||
use windmill_api_auth::{
|
||||
check_scopes, create_token_internal, require_is_writer, ApiAuthed, NewToken,
|
||||
build_scope_path_filter, check_scopes, create_token_internal, require_is_writer, ApiAuthed,
|
||||
NewToken,
|
||||
};
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
@@ -701,6 +702,7 @@ async fn list_native_triggers_handler<T: External>(
|
||||
query.per_page,
|
||||
query.path.as_deref(),
|
||||
query.is_flow,
|
||||
build_scope_path_filter(&authed, "native_triggers", "read"),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -56,7 +56,7 @@ use windmill_queue::PushArgsOwned;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use windmill_oauth::{ErrorField, ExecuteError, OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_api_auth::{ApiAuthed, ScopePathFilter};
|
||||
pub mod handler;
|
||||
pub(crate) mod lock;
|
||||
pub mod sync;
|
||||
@@ -1493,9 +1493,17 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
per_page: Option<usize>,
|
||||
path: Option<&str>,
|
||||
is_flow: Option<bool>,
|
||||
scope: ScopePathFilter,
|
||||
) -> Result<Vec<NativeTrigger>> {
|
||||
let offset = (page.unwrap_or(0) * per_page.unwrap_or(100)) as i64;
|
||||
let limit = per_page.unwrap_or(100) as i64;
|
||||
// In the WHERE, not a retain after the fetch: the result is paginated, and a
|
||||
// post-fetch filter would let a page's size report how many rows the token
|
||||
// may not read.
|
||||
let (scope_all, scope_exact, scope_prefix) = match scope {
|
||||
ScopePathFilter::AllowAll => (true, Vec::new(), Vec::new()),
|
||||
ScopePathFilter::Restricted { exact, prefix } => (false, exact, prefix),
|
||||
};
|
||||
|
||||
let triggers = sqlx::query_as!(
|
||||
NativeTrigger,
|
||||
@@ -1532,7 +1540,12 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
WHERE f.workspace_id = nt.workspace_id
|
||||
AND f.path = nt.script_path
|
||||
))
|
||||
)
|
||||
) AND
|
||||
( $7
|
||||
OR nt.script_path = ANY($8)
|
||||
OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx
|
||||
WHERE nt.script_path = pfx
|
||||
OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )
|
||||
LIMIT $3
|
||||
OFFSET $4
|
||||
"#,
|
||||
@@ -1541,7 +1554,10 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
limit,
|
||||
offset,
|
||||
path,
|
||||
is_flow
|
||||
is_flow,
|
||||
scope_all,
|
||||
&scope_exact[..],
|
||||
&scope_prefix[..],
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
@@ -214,8 +214,17 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
workspace_id
|
||||
);
|
||||
|
||||
let windmill_triggers =
|
||||
list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None, None, None).await?;
|
||||
let windmill_triggers = list_native_triggers(
|
||||
db,
|
||||
workspace_id,
|
||||
T::SERVICE_NAME,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
windmill_api_auth::ScopePathFilter::AllowAll,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if windmill_triggers.is_empty() {
|
||||
tracing::info!(
|
||||
|
||||
@@ -1288,7 +1288,25 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
}
|
||||
|
||||
let mut _skip_downstream_error_handlers = false;
|
||||
tx = delete_job(tx, &job_id).warn_after_seconds(10).await?;
|
||||
let (ntx, canceled_at_delete) = delete_job(tx, &job_id).warn_after_seconds(10).await?;
|
||||
tx = ntx;
|
||||
// `canceled_by` is only what the worker last read from the queue row. Deleting the row waits
|
||||
// for a cancel still being written, so the row it removed is the final word on whether this
|
||||
// job was canceled.
|
||||
if canceled_by.is_none() {
|
||||
if let Some(canceled) = canceled_at_delete {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, \
|
||||
canceled_reason = $3 WHERE id = $1",
|
||||
job_id,
|
||||
canceled.username,
|
||||
canceled.reason,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
// tracing::error!("3 {:?}", start.elapsed());
|
||||
|
||||
if completed_job.is_flow_step() {
|
||||
@@ -1614,6 +1632,22 @@ async fn restart_job_if_perpetual_inner(
|
||||
};
|
||||
|
||||
if restart {
|
||||
// Not `canceled_by`: a worker reads the queue row on a widening interval, up to every 5s
|
||||
// once a job has run for a minute, so a cancel landing after its last read reaches a
|
||||
// completion carrying none. `commit_completed_job` records it from the queue row it
|
||||
// deletes, so the completed row is what says whether this loop was stopped.
|
||||
let canceled = sqlx::query_scalar!(
|
||||
"SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed \
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
queued_job.id,
|
||||
&queued_job.workspace_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if canceled {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
|
||||
|
||||
// perpetual jobs can run one job per 10s max. If the job was faster than 10s, schedule the next one with the appropriate delay
|
||||
@@ -4816,6 +4850,72 @@ pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// The queue an explicit `tag` sends a job pushed with `args` to, or `None` when `push` drops the
|
||||
/// tag and the job runs on its default one.
|
||||
pub async fn resolve_push_tag(
|
||||
tag: &str,
|
||||
args: &PushArgs<'_>,
|
||||
workspace_id: &str,
|
||||
db: &DB,
|
||||
) -> Option<String> {
|
||||
// The flow runtime resolves a step's `$flow_expr[...]` before pushing it, so one still here
|
||||
// was pushed with no flow state to read (a step test, a dependency job) and would name a
|
||||
// queue no worker serves: the job runs on its default tag instead.
|
||||
if tag.is_empty() || tag_reads_flow_expr(tag) {
|
||||
return None;
|
||||
}
|
||||
// `$workspace` must resolve the same way the default tags do, or an explicit tag and a default
|
||||
// tag from the same workspace address two different worker pools. Resolving costs a lookup,
|
||||
// so pay it only for tags that actually interpolate `$workspace`.
|
||||
let tag_ws = if tag.contains("$workspace") {
|
||||
crate::tags::tag_workspace_id(workspace_id, db).await
|
||||
} else {
|
||||
workspace_id.to_string()
|
||||
};
|
||||
Some(interpolate_args(tag.to_string(), args, &tag_ws))
|
||||
}
|
||||
|
||||
/// Refuses a `tag` the caller chose that the instance's custom tags do not let `w_id` use,
|
||||
/// judging the queue it resolves to. `args` must be the ones the job is pushed with: resolving
|
||||
/// with any others checks a queue the job does not land on.
|
||||
pub async fn check_tag_available_for_push(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
tag: &str,
|
||||
args: &PushArgs<'_>,
|
||||
is_super_admin: bool,
|
||||
scope_tags: Option<Vec<&str>>,
|
||||
) -> Result<(), Error> {
|
||||
check_tag_written_as_available_for_push(db, w_id, tag, tag, args, is_super_admin, scope_tags)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`check_tag_available_for_push`] for a `tag` the flow runtime already partly resolved from
|
||||
/// `written_tag`, the step's tag as its author wrote it.
|
||||
pub async fn check_tag_written_as_available_for_push(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
written_tag: &str,
|
||||
tag: &str,
|
||||
args: &PushArgs<'_>,
|
||||
is_super_admin: bool,
|
||||
scope_tags: Option<Vec<&str>>,
|
||||
) -> Result<(), Error> {
|
||||
let Some(resolved_tag) = resolve_push_tag(tag, args, w_id, db).await else {
|
||||
return Ok(());
|
||||
};
|
||||
windmill_common::jobs::check_tag_available_for_workspace_internal(
|
||||
db,
|
||||
w_id,
|
||||
written_tag,
|
||||
Some(&resolved_tag),
|
||||
crate::tags::tag_workspace_id(w_id, db),
|
||||
is_super_admin,
|
||||
scope_tags,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn fullpath_with_workspace(
|
||||
workspace_id: &str,
|
||||
script_path: Option<&String>,
|
||||
@@ -5207,34 +5307,44 @@ async fn extract_result_from_job_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// Also reports the cancellation the deleted row carried, if any. Unlike a plain read of the queue
|
||||
/// row, this waits for a cancel that is still being written, so it is the last word on one.
|
||||
pub async fn delete_job<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
job_id: &Uuid,
|
||||
) -> windmill_common::error::Result<Transaction<'c, Postgres>> {
|
||||
) -> windmill_common::error::Result<(Transaction<'c, Postgres>, Option<CanceledBy>)> {
|
||||
#[cfg(feature = "prometheus")]
|
||||
if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
QUEUE_DELETE_COUNT.inc();
|
||||
}
|
||||
otel_incr_queue_delete_count();
|
||||
|
||||
let job_removed =
|
||||
sqlx::query_scalar!("DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", job_id,)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
let job_removed = sqlx::query!(
|
||||
"DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason",
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
|
||||
if let Err(job_removed) = job_removed {
|
||||
tracing::error!(
|
||||
"Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"
|
||||
);
|
||||
} else {
|
||||
let job_removed = job_removed.unwrap().flatten().unwrap_or(0);
|
||||
if job_removed != 1 {
|
||||
tracing::error!("Job {job_id} could not be deleted, returned not 1: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling");
|
||||
let canceled = match &job_removed {
|
||||
Err(job_removed) => {
|
||||
tracing::error!(
|
||||
"Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::error!("Job {job_id} could not be deleted, no row was removed. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling");
|
||||
None
|
||||
}
|
||||
Ok(Some(row)) => row.canceled_by.as_ref().map(|username| CanceledBy {
|
||||
username: Some(username.clone()),
|
||||
reason: row.canceled_reason.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
tracing::debug!("Job {job_id} deleted");
|
||||
Ok(tx)
|
||||
Ok((tx, canceled))
|
||||
}
|
||||
|
||||
pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result<bool> {
|
||||
@@ -6656,23 +6766,9 @@ async fn push_inner<'c, 'd>(
|
||||
);
|
||||
windmill_common::worker::dedicated_worker_tag(workspace_id, &full_path)
|
||||
} else {
|
||||
// The flow runtime resolves a step's `$flow_expr[...]` before pushing it, so one still here
|
||||
// was pushed with no flow state to read (a step test, a dependency job) and would name a
|
||||
// queue no worker serves: the job runs on its default tag instead.
|
||||
if tag == Some("".to_string()) || tag.as_deref().is_some_and(tag_reads_flow_expr) {
|
||||
tag = None;
|
||||
}
|
||||
|
||||
// `$workspace` must resolve the same way the default tags below do, or an explicit tag and
|
||||
// a default tag from the same workspace address two different worker pools. Resolving costs
|
||||
// a lookup, so pay it only for tags that actually interpolate `$workspace`.
|
||||
let interpolated_tag = match tag {
|
||||
Some(x) => resolve_push_tag(&x, &args, workspace_id, db).await,
|
||||
None => None,
|
||||
Some(x) if x.contains("$workspace") => {
|
||||
let tag_ws = crate::tags::tag_workspace_id(&workspace_id, db).await;
|
||||
Some(interpolate_args(x, &args, &tag_ws))
|
||||
}
|
||||
Some(x) => Some(interpolate_args(x, &args, workspace_id)),
|
||||
};
|
||||
let effective_ws = per_workspace_tag(&workspace_id, db).await;
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ use windmill_common::ee_oss::LICENSE_KEY_VALID;
|
||||
use windmill_common::flows::Retry;
|
||||
use windmill_common::get_flow_version_info_from_version;
|
||||
use windmill_common::get_latest_flow_version_id_for_path;
|
||||
use windmill_common::jobs::check_tag_available_for_workspace_internal;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_common::jobs::JobTriggerKind;
|
||||
use windmill_common::jobs::OnBehalfOf;
|
||||
@@ -511,14 +510,15 @@ pub async fn push_scheduled_job<'c>(
|
||||
|
||||
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
|
||||
let is_super_admin = windmill_common::auth::is_super_admin_email(db, &email).await?;
|
||||
check_tag_available_for_workspace_internal(
|
||||
crate::check_tag_available_for_push(
|
||||
db,
|
||||
&schedule.workspace_id,
|
||||
&tag,
|
||||
&crate::PushArgs::from(&args),
|
||||
is_super_admin,
|
||||
None, // no token for schedules so no scopes so no scope_tags
|
||||
)
|
||||
.warn_after_seconds_with_sql(1, "check_tag_available_for_workspace_internal".to_string())
|
||||
.warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string())
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use windmill_common::global_settings::{validate_allowed_origins, HTTP_ROUTE_WORK
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
utils::check_proper_path,
|
||||
worker::CLOUD_HOSTED,
|
||||
DB,
|
||||
};
|
||||
@@ -269,6 +270,7 @@ pub async fn create_many_http_triggers(
|
||||
check_scopes(&authed, || {
|
||||
format!("http_triggers:write:{}", &new_http_trigger.base.path)
|
||||
})?;
|
||||
check_proper_path(&new_http_trigger.base.path)?;
|
||||
|
||||
// This route inserts directly, bypassing the shared create handler.
|
||||
// `error_wrapper` would turn the rejection into a 500.
|
||||
|
||||
@@ -22,7 +22,7 @@ use windmill_common::{
|
||||
fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay,
|
||||
WithDraftQuery,
|
||||
},
|
||||
utils::{paginate, Pagination, StripPath},
|
||||
utils::{check_proper_path, paginate, Pagination, StripPath},
|
||||
worker::CLOUD_HOSTED,
|
||||
DB,
|
||||
};
|
||||
@@ -547,6 +547,7 @@ async fn create_trigger<T: TriggerCrud>(
|
||||
&new_trigger.base.path
|
||||
)
|
||||
})?;
|
||||
check_proper_path(&new_trigger.base.path)?;
|
||||
|
||||
if *CLOUD_HOSTED && !T::IS_ALLOWED_ON_CLOUD {
|
||||
return Err(Error::BadRequest(format!(
|
||||
@@ -817,6 +818,9 @@ async fn update_trigger<T: TriggerCrud>(
|
||||
&edit_trigger.base.path
|
||||
)
|
||||
})?;
|
||||
if edit_trigger.base.path != path {
|
||||
check_proper_path(&edit_trigger.base.path)?;
|
||||
}
|
||||
|
||||
edit_trigger.error_handling.validate()?;
|
||||
|
||||
|
||||
@@ -880,7 +880,8 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
delete_after_secs,
|
||||
);
|
||||
|
||||
check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?;
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
check_tag_available_for_workspace(&db, &workspace_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let return_tx = tx_o.is_some();
|
||||
|
||||
@@ -907,8 +908,6 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
)
|
||||
};
|
||||
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
|
||||
let retryable_job_payload = match job_payload {
|
||||
JobPayload::ScriptHash {
|
||||
hash,
|
||||
|
||||
@@ -2689,13 +2689,13 @@ try {{
|
||||
/// Resolve a module file from the parent script's modules map.
|
||||
/// For Script jobs, fetches from the `script` table by hash.
|
||||
/// For Preview jobs, fetches from `v2_job.raw_code` (modules stored inline).
|
||||
fn resolve_parent_module(
|
||||
modules: &Option<std::collections::HashMap<String, windmill_common::scripts::ScriptModule>>,
|
||||
fn resolve_parent_module<'a>(
|
||||
modules: &'a Option<std::collections::HashMap<String, windmill_common::scripts::ScriptModule>>,
|
||||
module_key: &str,
|
||||
) -> error::Result<windmill_common::scripts::ScriptModule> {
|
||||
) -> error::Result<&'a windmill_common::scripts::ScriptModule> {
|
||||
if let Some(modules) = modules {
|
||||
if let Some(module) = modules.get(module_key) {
|
||||
return Ok(module.clone());
|
||||
return Ok(module);
|
||||
}
|
||||
}
|
||||
Err(error::Error::ExecutionErr(format!(
|
||||
@@ -2716,7 +2716,7 @@ pub async fn handle_wac_v2_output(
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
use crate::wac_executor::{
|
||||
load_checkpoint, parse_wac_output, update_checkpoint_for_dispatch,
|
||||
wac_cancelled_mid_segment, WacOutput, WacPark,
|
||||
wac_cancelled_mid_segment, WacOutput, WacPark, WacStepDispatch,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use windmill_common::get_latest_flow_version_info_for_path;
|
||||
@@ -2724,7 +2724,7 @@ pub async fn handle_wac_v2_output(
|
||||
use windmill_common::runnable_settings::{
|
||||
ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings,
|
||||
};
|
||||
use windmill_queue::{push, PushArgs, PushIsolationLevel};
|
||||
use windmill_queue::{check_tag_available_for_push, push, PushArgs, PushIsolationLevel};
|
||||
|
||||
let output = parse_wac_output(&result)?;
|
||||
|
||||
@@ -2950,6 +2950,129 @@ pub async fn handle_wac_v2_output(
|
||||
))),
|
||||
}?;
|
||||
|
||||
// Resolve what every task runs, and check any tag it picks, before the parent parks:
|
||||
// a task that cannot be pushed then fails the dispatch with no sibling queued. The
|
||||
// parent's code and input are built per child at push time, so a fan-out never holds
|
||||
// a copy of them for each child at once.
|
||||
enum ChildRunnable<'a> {
|
||||
// Re-runs the parent with `_executing_key`.
|
||||
Parent,
|
||||
// A `./` module of the parent script.
|
||||
Module(&'a windmill_common::scripts::ScriptModule),
|
||||
// A deployed script or flow.
|
||||
Deployed(JobPayload),
|
||||
}
|
||||
struct ResolvedChild<'a> {
|
||||
runnable: ChildRunnable<'a>,
|
||||
email: String,
|
||||
permissioned_as: String,
|
||||
}
|
||||
// The input a task with a runnable of its own is pushed with; the parent re-run gets
|
||||
// the parent's. Both the tag check and the push build it here, so the check resolves
|
||||
// `$args[...]` against what the child actually receives.
|
||||
let own_args = |step: &WacStepDispatch| -> HashMap<String, Box<RawValue>> {
|
||||
step.args
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let raw = serde_json::value::to_raw_value(v).unwrap();
|
||||
(k.clone(), raw)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut children: Vec<ResolvedChild> = Vec::with_capacity(num_steps);
|
||||
for step in &steps {
|
||||
let (runnable, on_behalf_of) = match step.dispatch_type.as_str() {
|
||||
"script" if step.script.starts_with("./") => {
|
||||
// Module-relative path: resolve from parent script's modules
|
||||
let module_key = step.script.strip_prefix("./").unwrap();
|
||||
let module = resolve_parent_module(modules, module_key)?;
|
||||
// Inline module code, not a separate runnable: it has no
|
||||
// identity of its own and runs as the parent.
|
||||
(ChildRunnable::Module(module), None)
|
||||
}
|
||||
"script" => {
|
||||
// Resolve script path to job payload (handles hash, lang, etc.)
|
||||
let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload(
|
||||
&step.script,
|
||||
None, // no authed db for background workers
|
||||
db.clone(),
|
||||
&job.workspace_id,
|
||||
Some(true), // skip preprocessor
|
||||
)
|
||||
.await?;
|
||||
(ChildRunnable::Deployed(payload), on_behalf_of)
|
||||
}
|
||||
"flow" => {
|
||||
let flow_info = get_latest_flow_version_info_for_path(
|
||||
None,
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&step.script,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let payload = JobPayload::Flow {
|
||||
path: step.script.clone(),
|
||||
dedicated_worker: flow_info.dedicated_worker,
|
||||
apply_preprocessor: false,
|
||||
version: flow_info.version,
|
||||
labels: flow_info.labels.clone(),
|
||||
};
|
||||
let on_behalf_of = flow_info.on_behalf_of(&job.workspace_id, db).await?;
|
||||
(ChildRunnable::Deployed(payload), on_behalf_of)
|
||||
}
|
||||
// "inline" — re-run parent with _executing_key
|
||||
_ => (ChildRunnable::Parent, None),
|
||||
};
|
||||
|
||||
// A target runnable that opts into on-behalf-of runs under its own
|
||||
// identity, never the caller's, so a step that reaches it through a
|
||||
// workflow cannot widen or narrow its permissions. `created_by` still
|
||||
// credits the caller, matching how the run API pushes these jobs.
|
||||
let (email, permissioned_as) = match on_behalf_of {
|
||||
Some(on_behalf_of) => (on_behalf_of.email, on_behalf_of.permissioned_as),
|
||||
None => (
|
||||
job.permissioned_as_email.clone(),
|
||||
job.permissioned_as.clone(),
|
||||
),
|
||||
};
|
||||
|
||||
// A task inheriting the parent's tag skips the check: that tag was
|
||||
// checked when the parent was pushed, and a dedicated worker's tag is
|
||||
// never in CUSTOM_TAGS.
|
||||
if let Some(tag) = step
|
||||
.tag
|
||||
.as_deref()
|
||||
.filter(|t| !t.is_empty() && *t != job.tag.as_str())
|
||||
{
|
||||
let is_super_admin =
|
||||
windmill_common::auth::is_super_admin_email(db, &email).await?;
|
||||
let step_args =
|
||||
(!matches!(runnable, ChildRunnable::Parent)).then(|| own_args(step));
|
||||
let args =
|
||||
PushArgs { args: step_args.as_ref().unwrap_or(&parent_args), extra: None };
|
||||
check_tag_available_for_push(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
tag,
|
||||
&args,
|
||||
is_super_admin,
|
||||
None,
|
||||
)
|
||||
.warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
error::Error::BadRequest(msg) => error::Error::BadRequest(format!(
|
||||
"task '{}' cannot run on tag '{tag}': {msg}",
|
||||
step.name
|
||||
)),
|
||||
e => e,
|
||||
})?;
|
||||
}
|
||||
|
||||
children.push(ResolvedChild { runnable, email, permissioned_as });
|
||||
}
|
||||
|
||||
// Step 1: Save checkpoint, suspend parent, and seed child checkpoints
|
||||
// in a single transaction — all BEFORE children become visible.
|
||||
let segment_ms;
|
||||
@@ -3034,106 +3157,37 @@ pub async fn handle_wac_v2_output(
|
||||
// partial failure (e.g. pushing child 3 of 5 fails).
|
||||
let mut pushed_ids: Vec<Uuid> = Vec::with_capacity(num_steps);
|
||||
let push_result: error::Result<()> = async {
|
||||
for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) {
|
||||
for ((step, (_, child_uuid)), child) in
|
||||
steps.iter().zip(job_ids.iter()).zip(children)
|
||||
{
|
||||
// A task with a runnable of its own (a deployed script or flow) queues
|
||||
// at that runnable's priority; any other task is the parent's code and
|
||||
// queues at the parent's.
|
||||
let own_runnable = matches!(step.dispatch_type.as_str(), "script" | "flow")
|
||||
&& !step.script.starts_with("./");
|
||||
|
||||
// Resolve job payload based on dispatch_type
|
||||
let (job_payload, child_args, is_external, on_behalf_of) =
|
||||
match step.dispatch_type.as_str() {
|
||||
"script" if step.script.starts_with("./") => {
|
||||
// Module-relative path: resolve from parent script's modules
|
||||
let module_key = step.script.strip_prefix("./").unwrap();
|
||||
let module = resolve_parent_module(modules, module_key)?;
|
||||
let payload = JobPayload::Code(RawCode {
|
||||
content: module.content,
|
||||
path: job.runnable_path.clone(),
|
||||
hash: None,
|
||||
language: module.language,
|
||||
lock: module.lock,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: ConcurrencySettingsWithCustom::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
modules: None,
|
||||
tag: None,
|
||||
});
|
||||
let step_args: HashMap<String, Box<RawValue>> = step
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let raw = serde_json::value::to_raw_value(v).unwrap();
|
||||
(k.clone(), raw)
|
||||
})
|
||||
.collect();
|
||||
// Inline module code, not a separate runnable: it has no
|
||||
// identity of its own and runs as the parent.
|
||||
(payload, step_args, true, None)
|
||||
}
|
||||
"script" => {
|
||||
// Resolve script path to job payload (handles hash, lang, etc.)
|
||||
let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload(
|
||||
&step.script,
|
||||
None, // no authed db for background workers
|
||||
db.clone(),
|
||||
&job.workspace_id,
|
||||
Some(true), // skip preprocessor
|
||||
)
|
||||
.await?;
|
||||
let step_args: HashMap<String, Box<RawValue>> = step
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let raw = serde_json::value::to_raw_value(v).unwrap();
|
||||
(k.clone(), raw)
|
||||
})
|
||||
.collect();
|
||||
(payload, step_args, true, on_behalf_of)
|
||||
}
|
||||
"flow" => {
|
||||
let flow_info = get_latest_flow_version_info_for_path(
|
||||
None,
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&step.script,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let payload = JobPayload::Flow {
|
||||
path: step.script.clone(),
|
||||
dedicated_worker: flow_info.dedicated_worker,
|
||||
apply_preprocessor: false,
|
||||
version: flow_info.version,
|
||||
labels: flow_info.labels.clone(),
|
||||
};
|
||||
let on_behalf_of =
|
||||
flow_info.on_behalf_of(&job.workspace_id, db).await?;
|
||||
let step_args: HashMap<String, Box<RawValue>> = step
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let raw = serde_json::value::to_raw_value(v).unwrap();
|
||||
(k.clone(), raw)
|
||||
})
|
||||
.collect();
|
||||
(payload, step_args, true, on_behalf_of)
|
||||
}
|
||||
_ => {
|
||||
// "inline" — re-run parent with _executing_key
|
||||
(
|
||||
job_payload_template.clone(),
|
||||
parent_args.clone(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let push_args = PushArgs { args: &child_args, extra: None };
|
||||
let is_external = !matches!(child.runnable, ChildRunnable::Parent);
|
||||
let job_payload = match child.runnable {
|
||||
ChildRunnable::Parent => job_payload_template.clone(),
|
||||
ChildRunnable::Module(module) => JobPayload::Code(RawCode {
|
||||
content: module.content.clone(),
|
||||
path: job.runnable_path.clone(),
|
||||
hash: None,
|
||||
language: module.language,
|
||||
lock: module.lock.clone(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: ConcurrencySettingsWithCustom::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
modules: None,
|
||||
tag: None,
|
||||
}),
|
||||
ChildRunnable::Deployed(payload) => payload,
|
||||
};
|
||||
let step_args = is_external.then(|| own_args(step));
|
||||
let push_args =
|
||||
PushArgs { args: step_args.as_ref().unwrap_or(&parent_args), extra: None };
|
||||
|
||||
// Apply step-level overrides to payload (cache, concurrency)
|
||||
let mut job_payload = job_payload;
|
||||
@@ -3181,21 +3235,6 @@ pub async fn handle_wac_v2_output(
|
||||
}
|
||||
}
|
||||
|
||||
// A target runnable that opts into on-behalf-of runs under its own
|
||||
// identity, never the caller's, so a step that reaches it through a
|
||||
// workflow cannot widen or narrow its permissions. `created_by` still
|
||||
// credits the caller, matching how the run API pushes these jobs.
|
||||
let (child_email, child_permissioned_as) = match on_behalf_of.as_ref() {
|
||||
Some(on_behalf_of) => (
|
||||
on_behalf_of.email.as_str(),
|
||||
on_behalf_of.permissioned_as.clone(),
|
||||
),
|
||||
None => (
|
||||
job.permissioned_as_email.as_str(),
|
||||
job.permissioned_as.clone(),
|
||||
),
|
||||
};
|
||||
|
||||
let (_, mut tx) = push(
|
||||
db,
|
||||
PushIsolationLevel::IsolatedRoot(db.clone()),
|
||||
@@ -3203,8 +3242,8 @@ pub async fn handle_wac_v2_output(
|
||||
job_payload,
|
||||
push_args,
|
||||
&job.created_by,
|
||||
child_email,
|
||||
child_permissioned_as,
|
||||
&child.email,
|
||||
child.permissioned_as,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -44,8 +44,7 @@ use windmill_common::flow_status::{
|
||||
};
|
||||
use windmill_common::flows::{add_virtual_items_if_necessary, Branch, FlowNodeId, StopAfterIf};
|
||||
use windmill_common::jobs::{
|
||||
check_tag_available_for_workspace_internal, script_path_to_payload, JobKind, JobPayload,
|
||||
OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE,
|
||||
script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE,
|
||||
};
|
||||
use windmill_common::runnable_settings::{
|
||||
ConcurrencySettingsWithCustom, DebouncingSettings, RunnableSettingsTrait,
|
||||
@@ -68,11 +67,12 @@ use windmill_common::{
|
||||
};
|
||||
use windmill_queue::schedule::get_schedule_opt;
|
||||
use windmill_queue::{
|
||||
add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job,
|
||||
insert_concurrency_key_capped, interpolate_args, render_tag_path,
|
||||
report_error_to_workspace_handler_or_critical_side_channel, tag_reads_args,
|
||||
tag_reads_flow_expr, try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob,
|
||||
MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError, RE_FLOW_EXPR_TAG,
|
||||
add_completed_job, add_completed_job_error, append_logs,
|
||||
check_tag_written_as_available_for_push, get_mini_pulled_job, insert_concurrency_key_capped,
|
||||
render_tag_path, report_error_to_workspace_handler_or_critical_side_channel, resolve_push_tag,
|
||||
tag_reads_args, tag_reads_flow_expr, try_schedule_next_job, CanceledBy, FlowRunners,
|
||||
MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError,
|
||||
RE_FLOW_EXPR_TAG,
|
||||
};
|
||||
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
@@ -1520,15 +1520,6 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
.is_some_and(|ck| ck.contains("$args"))
|
||||
});
|
||||
let require_args = concurrency_requires_args || has_debouncing;
|
||||
let mut tag = tag_and_concurrency_key.as_ref().and_then(|x| x.tag.clone());
|
||||
// `$workspace` does not depend on the preprocessor's output, so it has to resolve even
|
||||
// when nothing forced us to fetch args. Leaving it to the `$args` branch below writes a
|
||||
// `$workspace`-only tag back verbatim, naming a queue no worker serves.
|
||||
if let Some(t) = tag.as_ref().filter(|t| t.contains("$workspace")) {
|
||||
let tag_ws =
|
||||
windmill_queue::tags::tag_workspace_id(&flow_job.workspace_id, db).await;
|
||||
tag = Some(t.replace("$workspace", &tag_ws));
|
||||
}
|
||||
let concurrency_key = tag_and_concurrency_key
|
||||
.as_ref()
|
||||
.and_then(|x| x.concurrency_key.clone());
|
||||
@@ -1576,10 +1567,6 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if let Some(t) = tag {
|
||||
// `$workspace` is already resolved above; this fills in `$args`.
|
||||
tag = Some(interpolate_args(t, &args, &flow_job.workspace_id));
|
||||
}
|
||||
} else if concurrent_limit.is_some() {
|
||||
insert_concurrency_key_capped(
|
||||
&flow_job.workspace_id,
|
||||
@@ -1594,6 +1581,23 @@ pub async fn update_flow_status_after_job_completion_internal(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Resolved as `push` resolves a tag, so a `$flow_expr[...]` or empty one comes out as
|
||||
// `None` and the job keeps its current tag instead of queueing on the literal text.
|
||||
let tag = match tag_and_concurrency_key
|
||||
.as_ref()
|
||||
.and_then(|x| x.tag.as_deref())
|
||||
{
|
||||
// `run_flow` checked this tag for whoever ran the flow, an `$args[...]` in it as
|
||||
// written since only this point knows its values: an entry admitting it as
|
||||
// written admits every value here.
|
||||
Some(t) => {
|
||||
let no_args = HashMap::new();
|
||||
let args = PushArgs::from(fetched_args.as_ref().unwrap_or(&no_args));
|
||||
resolve_push_tag(t, &args, &flow_job.workspace_id, db).await
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let scheduled_for: Option<chrono::DateTime<chrono::Utc>> = {
|
||||
#[cfg(feature = "private")]
|
||||
{
|
||||
@@ -4494,33 +4498,7 @@ async fn push_next_flow_job(
|
||||
)
|
||||
};
|
||||
|
||||
// Check tag availability for flow substeps to prevent abuse.
|
||||
// Skip when the substep inherits the parent flow's tag: that tag was already
|
||||
// validated at flow push time, and for dedicated flows it is the auto-generated
|
||||
// `{workspace_id}:flow/{path}` tag which is never in CUSTOM_TAGS.
|
||||
if let Some(tag_str) = tag
|
||||
.as_deref()
|
||||
.filter(|t| !t.is_empty() && *t != flow_job.tag.as_str())
|
||||
{
|
||||
// A step with its own on-behalf-of carries a cached dispatch address, up to one
|
||||
// notify poll stale; accepted, see `get_email_from_permissioned_as`.
|
||||
let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?;
|
||||
check_tag_available_for_workspace_internal(
|
||||
db,
|
||||
&flow_job.workspace_id,
|
||||
tag_str,
|
||||
is_super_admin,
|
||||
None, // no token for flow substeps so no scopes so no scope_tags
|
||||
)
|
||||
.warn_after_seconds_with_sql(
|
||||
1,
|
||||
"check_tag_available_for_workspace_internal".to_string(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Resolved only after the check: CUSTOM_TAGS allows the template, so its value may name
|
||||
// any queue, as the value of an `$args[...]` tag does.
|
||||
let written_tag = tag.clone();
|
||||
let mut tag_err = None;
|
||||
let tag = match tag {
|
||||
Some(t) if err.is_none() && tag_reads_flow_expr(&t) => {
|
||||
@@ -4543,6 +4521,31 @@ async fn push_next_flow_job(
|
||||
};
|
||||
let err = err.or(tag_err.as_ref());
|
||||
|
||||
// Check tag availability for flow substeps to prevent abuse. It runs on the tag with its
|
||||
// `$flow_expr[...]` filled in, as `push` fills in the rest, since that is what picks the
|
||||
// queue. Skip when the substep inherits the parent flow's tag: that tag was already
|
||||
// validated at flow push time, and for dedicated flows it is the auto-generated
|
||||
// `{workspace_id}:flow/{path}` tag which is never in CUSTOM_TAGS.
|
||||
if let Some(tag_str) = tag
|
||||
.as_deref()
|
||||
.filter(|t| !t.is_empty() && *t != flow_job.tag.as_str())
|
||||
{
|
||||
// A step with its own on-behalf-of carries a cached dispatch address, up to one
|
||||
// notify poll stale; accepted, see `get_email_from_permissioned_as`.
|
||||
let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?;
|
||||
check_tag_written_as_available_for_push(
|
||||
db,
|
||||
&flow_job.workspace_id,
|
||||
written_tag.as_deref().unwrap_or(tag_str),
|
||||
tag_str,
|
||||
&push_args,
|
||||
is_super_admin,
|
||||
None, // no token for flow substeps so no scopes so no scope_tags
|
||||
)
|
||||
.warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string())
|
||||
.await?;
|
||||
}
|
||||
|
||||
let evaluated_timeout = if let Some(timeout_transform) = &module.timeout {
|
||||
let ctx = get_transform_context(&flow_job, &previous_id, &status);
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.816.0";
|
||||
export const VERSION = "v1.817.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"version": "1.816.0",
|
||||
"version": "1.817.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-chat",
|
||||
"version": "1.816.0",
|
||||
"version": "1.817.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.816.0",
|
||||
"version": "1.817.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
@@ -1153,7 +1153,7 @@ export function ZipFSElement(
|
||||
? "script"
|
||||
: p.endsWith(".resource.json")
|
||||
? "resource"
|
||||
: p.startsWith("dependencies/")
|
||||
: isWorkspaceDependencies(p)
|
||||
? "dependencies"
|
||||
: "other";
|
||||
|
||||
@@ -2752,7 +2752,7 @@ export async function compareDynFSElement(
|
||||
if (skipMetadata) {
|
||||
continue;
|
||||
}
|
||||
if (k.startsWith("dependencies/")) {
|
||||
if (isWorkspaceDependencies(k)) {
|
||||
if (!workspaceDependenciesPathToLanguageAndFilename(k)) {
|
||||
log.warn(`Skipping unrecognized workspace dependencies file: ${k}`);
|
||||
continue;
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.816.0";
|
||||
export const VERSION = "1.817.0";
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ export function getTypeStrFromPath(
|
||||
if (isSharedLockPath(p)) {
|
||||
return "shared_lock";
|
||||
}
|
||||
if (p.startsWith("dependencies" + SEP)) {
|
||||
if (isWorkspaceDependencies(p)) {
|
||||
return "workspace_dependencies";
|
||||
}
|
||||
if (isFileResource(p) || isFilesetResource(p)) {
|
||||
|
||||
@@ -45,7 +45,9 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [
|
||||
] as const;
|
||||
|
||||
export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined {
|
||||
const relativePath = path.replace("dependencies/", "");
|
||||
// Sync paths carry the platform separator, while a set's name is always
|
||||
// forward-slashed.
|
||||
const relativePath = path.replaceAll("\\", "/").replace("dependencies/", "");
|
||||
for (const { filename, language } of workspaceDependenciesLanguages) {
|
||||
if (relativePath.endsWith(filename)) {
|
||||
return {
|
||||
|
||||
@@ -223,8 +223,9 @@ export function isRawAppFile(path: string): boolean {
|
||||
return isRawAppPath(path);
|
||||
}
|
||||
|
||||
/** Sync paths carry the platform separator: `dependencies\x` on Windows. */
|
||||
export function isWorkspaceDependencies(path: string): boolean {
|
||||
return path.startsWith("dependencies/");
|
||||
return path.startsWith("dependencies/") || path.startsWith("dependencies\\");
|
||||
}
|
||||
|
||||
export function printSync(input: string | Uint8Array) {
|
||||
|
||||
@@ -63,7 +63,10 @@ test("git-sync fork deploy follows the fork branch's wmill.yaml", async () => {
|
||||
git(seed, "commit", "-m", "dedupe");
|
||||
git(seed, "push", "origin", forkBranch);
|
||||
|
||||
git(work, "clone", `file://${bare}`, ".");
|
||||
// Git for Windows defaults to autocrlf=true, which checks the pulled LF
|
||||
// files out as CRLF; the deploy's pull would rewrite every one of them
|
||||
// and the clean-tree assertion below would read that as a change.
|
||||
git(work, "clone", "-c", "core.autocrlf=false", `file://${bare}`, ".");
|
||||
const deployed = await backend.runCLICommand(
|
||||
[
|
||||
"sync",
|
||||
|
||||
@@ -2185,18 +2185,20 @@ describe("settings sync", () => {
|
||||
const files = await listFilesRecursive(tempDir);
|
||||
expect(files).toContain("settings.yaml");
|
||||
|
||||
// Read and modify a safe setting (webhook URL)
|
||||
// Read and modify a safe setting (webhook URL). The server resolves the
|
||||
// webhook host when it is saved and refuses one that does not resolve to a
|
||||
// public address, so this must be a real public hostname.
|
||||
const webhook = "https://example.com/hook";
|
||||
const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8");
|
||||
|
||||
let modifiedSettings: string;
|
||||
if (settingsContent.includes("webhook:")) {
|
||||
modifiedSettings = settingsContent.replace(
|
||||
/webhook:.*/,
|
||||
'webhook: "https://test-webhook.example.com/hook"'
|
||||
`webhook: "${webhook}"`
|
||||
);
|
||||
} else {
|
||||
modifiedSettings =
|
||||
settingsContent + '\nwebhook: "https://test-webhook.example.com/hook"\n';
|
||||
modifiedSettings = settingsContent + `\nwebhook: "${webhook}"\n`;
|
||||
}
|
||||
await writeFile(`${tempDir}/settings.yaml`, modifiedSettings, "utf-8");
|
||||
|
||||
@@ -2213,7 +2215,7 @@ describe("settings sync", () => {
|
||||
);
|
||||
expect(apiResp.status).toEqual(200);
|
||||
const settingsData = await apiResp.json();
|
||||
expect(settingsData.webhook).toEqual("https://test-webhook.example.com/hook");
|
||||
expect(settingsData.webhook).toEqual(webhook);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { isWorkspaceDependencies } from "../src/utils/utils.ts";
|
||||
import { workspaceDependenciesPathToLanguageAndFilename } from "../src/utils/script_common.ts";
|
||||
|
||||
// Sync paths carry the platform separator. A Windows path parsed to a different
|
||||
// set name pushes the file to a set the workspace does not have.
|
||||
test("workspace dependency paths parse the same with either separator", () => {
|
||||
for (const sep of ["/", "\\"]) {
|
||||
expect(isWorkspaceDependencies(`dependencies${sep}requirements.in`)).toBe(true);
|
||||
expect(
|
||||
workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}requirements.in`),
|
||||
).toEqual({ name: undefined, language: "python3" });
|
||||
expect(
|
||||
workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}team.package.json`),
|
||||
).toEqual({ name: "team", language: "bun" });
|
||||
expect(
|
||||
workspaceDependenciesPathToLanguageAndFilename(
|
||||
`dependencies${sep}team${sep}python.requirements.in`,
|
||||
),
|
||||
).toEqual({ name: "team/python", language: "python3" });
|
||||
}
|
||||
expect(isWorkspaceDependencies("f\\dependencies\\requirements.in")).toBe(false);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user