Merge remote-tracking branch 'origin/main' into dbt-column-lineage-index

This commit is contained in:
Ruben Fiszel
2026-09-07 14:56:56 +02:00
107 changed files with 3855 additions and 548 deletions
@@ -0,0 +1,56 @@
name: Sign image and attach provenance
description: >
Keyless-signs a pushed image digest with cosign (index and per-arch
manifests) and records SLSA provenance as a GitHub artifact attestation
pushed to the registry. SBOMs are not generated here: the build step embeds
them as BuildKit attestation manifests (depot `sbom: true`), which the index
signature then covers. The calling job must already be logged in to the
registry and must have id-token: write, attestations: write and
packages: write permissions (write-all covers all three).
inputs:
image:
description: "Fully-qualified image name without tag, e.g. ghcr.io/windmill-labs/windmill"
required: true
digest:
description: "Pushed manifest digest (sha256:...) from build-push-action"
required: true
runs:
using: composite
steps:
- name: Preflight
shell: bash
env:
DIGEST: ${{ inputs.digest }}
run: |
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "::error::No OIDC token available; the calling job needs id-token: write"
exit 1
fi
case "$DIGEST" in
sha256:*) ;;
*)
echo "::error::digest '$DIGEST' is not a sha256: digest"
exit 1
;;
esac
# cosign v2 writes the classic sha256-<digest>.sig tag format that the
# installed base of cosign clients can verify; v3's bundle format cannot be
# verified by v2 clients yet, so stay on v2 until v3 verification is common.
- uses: sigstore/cosign-installer@v4.1.2
with:
cosign-release: "v2.6.5"
- name: Cosign keyless sign (index + per-arch manifests)
shell: bash
env:
IMAGE: ${{ inputs.image }}
DIGEST: ${{ inputs.digest }}
run: cosign sign --yes --recursive "${IMAGE}@${DIGEST}"
- name: SLSA provenance (GitHub artifact attestation)
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ inputs.image }}
subject-digest: ${{ inputs.digest }}
push-to-registry: true
+13
View File
@@ -13,9 +13,13 @@ permissions:
contents: read
id-token: write
packages: write
attestations: write
jobs:
publish_cli:
# a tag-targeted dispatch would republish the release tags unsigned,
# un-verifying the release; to republish a release, re-push its tag
if: github.event_name == 'push' || !startsWith(github.ref, 'refs/tags/')
runs-on: ubicloud
steps:
- uses: actions/checkout@v4
@@ -42,14 +46,23 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly
id: docker_build
uses: depot/build-push-action@v1
with:
file: "./docker/DockerfileCli"
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.licenses=AGPLv3
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
digest: ${{ steps.docker_build.outputs.digest }}
+93
View File
@@ -86,11 +86,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Build and push publicly
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
build-args: |
features=ce
WM_BUILD_VERSION=${{ github.sha }}
@@ -100,6 +102,13 @@ jobs:
labels: |
${{ steps.meta-public.outputs.labels }}
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
digest: ${{ steps.docker_build.outputs.digest }}
build_ee:
runs-on: ubicloud
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
@@ -149,11 +158,13 @@ jobs:
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Build and push publicly ee
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
build-args: |
features=ee
WM_BUILD_VERSION=${{ github.sha }}
@@ -164,6 +175,13 @@ jobs:
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee
digest: ${{ steps.docker_build.outputs.digest }}
attach_amd64_binary_to_release:
needs: [build, build_ee]
runs-on: ubicloud
@@ -358,6 +376,21 @@ jobs:
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
- uses: sigstore/cosign-installer@v4.1.2
if: startsWith(github.ref, 'refs/tags/v')
with:
cosign-release: "v2.6.5"
# end-to-end release guard: the version tag pushed by this run must
# verify against this exact run's identity (the mutable :latest/:dev
# tags race with concurrent main builds, so they are not asserted here)
- name: Verify release image is signed
if: startsWith(github.ref, 'refs/tags/v')
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity "https://github.com/windmill-labs/windmill/.github/workflows/docker-image.yml@${GITHUB_REF}" \
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_REF_NAME#v}"
tag_latest_ee:
runs-on: ubicloud
needs: [run_integration_test, build_ee]
@@ -379,6 +412,21 @@ jobs:
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
- uses: sigstore/cosign-installer@v4.1.2
if: startsWith(github.ref, 'refs/tags/v')
with:
cosign-release: "v2.6.5"
# end-to-end release guard: the version tag pushed by this run must
# verify against this exact run's identity (the mutable :latest/:dev
# tags race with concurrent main builds, so they are not asserted here)
- name: Verify release ee image is signed
if: startsWith(github.ref, 'refs/tags/v')
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity "https://github.com/windmill-labs/windmill/.github/workflows/docker-image.yml@${GITHUB_REF}" \
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${GITHUB_REF_NAME#v}"
verify_ee_image_vulnerabilities:
runs-on: ubicloud
needs: [tag_latest_ee]
@@ -493,11 +541,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly ee
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileCuda"
tags: |
${{ steps.meta-ee-public.outputs.tags }}
@@ -505,6 +555,13 @@ jobs:
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-cuda
digest: ${{ steps.docker_build.outputs.digest }}
build_slim:
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs: [build]
@@ -537,17 +594,26 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly ee
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileSlim"
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
${{ steps.meta-ee-public.outputs.labels }}
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-slim
digest: ${{ steps.docker_build.outputs.digest }}
build_ee_slim:
needs: [build_ee]
runs-on: ubicloud
@@ -582,11 +648,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly ee
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileSlimEe"
tags: |
${{ steps.meta-ee-public.outputs.tags }}
@@ -594,6 +662,13 @@ jobs:
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-slim
digest: ${{ steps.docker_build.outputs.digest }}
build_full:
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs: [build]
@@ -626,17 +701,26 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileFull"
tags: |
${{ steps.meta-public.outputs.tags }}
labels: |
${{ steps.meta-public.outputs.labels }}
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-full
digest: ${{ steps.docker_build.outputs.digest }}
build_ee_full:
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs: [build_ee]
@@ -669,14 +753,23 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly ee
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
file: "./docker/DockerfileFullEe"
tags: |
${{ steps.meta-ee-public.outputs.tags }}
labels: |
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-full
digest: ${{ steps.docker_build.outputs.digest }}
+12
View File
@@ -84,6 +84,9 @@ jobs:
publish_extra:
needs: [sleep, test_extra]
# a tag-targeted dispatch would republish the release tags unsigned,
# un-verifying the release; to republish a release, re-push its tag
if: github.event_name == 'push' || !startsWith(github.ref, 'refs/tags/')
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
@@ -112,15 +115,24 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push publicly
id: docker_build
uses: depot/build-push-action@v1
with:
context: .
file: ./docker/DockerfileExtra
platforms: linux/amd64,linux/arm64
push: true
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
org.opencontainers.image.licenses=AGPLv3
- name: Sign and attest release image
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
uses: ./.github/actions/sign-attest-image
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
digest: ${{ steps.docker_build.outputs.digest }}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.803.0"
".": "1.804.0"
}
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## [1.804.0](https://github.com/windmill-labs/windmill/compare/v1.803.0...v1.804.0) (2026-09-05)
### Features
* **ai-sessions:** replace the context panel with an assistant settings modal ([#10919](https://github.com/windmill-labs/windmill/issues/10919)) ([fda7b3f](https://github.com/windmill-labs/windmill/commit/fda7b3f086619e3716e5894c07be127104174f1d))
* **frontend:** group the agent form and edit saved agents as drafts ([#10880](https://github.com/windmill-labs/windmill/issues/10880)) ([f037c73](https://github.com/windmill-labs/windmill/commit/f037c73d104fffe7bb2640a5b1f2a92154c85e06))
* guest app execution mode, a role that takes no seat ([#10929](https://github.com/windmill-labs/windmill/issues/10929)) ([fce635d](https://github.com/windmill-labs/windmill/commit/fce635d3c4c8962f448140ceb55a00fb99012701))
* guest JWT entry for embedded apps ([#10954](https://github.com/windmill-labs/windmill/issues/10954)) ([8aab503](https://github.com/windmill-labs/windmill/commit/8aab5034a68a4aafb264b0e86d000ef58f4a8511))
* instrument sandbox isolation, data tables and in-flow script edits ([#10981](https://github.com/windmill-labs/windmill/issues/10981)) ([130a2f7](https://github.com/windmill-labs/windmill/commit/130a2f74083ba1bd308beeb86e2cbbaa41fd3345))
* make S3 permission rules reorderable by drag and drop ([#10958](https://github.com/windmill-labs/windmill/issues/10958)) ([2257b05](https://github.com/windmill-labs/windmill/commit/2257b05b2857c7ae2b5ae0b4f9004e2d4e757925))
* reconcile IdP instance groups from the SSO groups claim ([#10957](https://github.com/windmill-labs/windmill/issues/10957)) ([79426a1](https://github.com/windmill-labs/windmill/commit/79426a1a68a6b19e12af4633b8a79d07a103a106))
### Bug Fixes
* deploy a relocked script version only when its lock changed ([#10966](https://github.com/windmill-labs/windmill/issues/10966)) ([1113828](https://github.com/windmill-labs/windmill/commit/11138284acc4c1d8673e86823c7f74c9e1f419e6))
* **frontend:** render ordered lists in markdown descriptions ([#10973](https://github.com/windmill-labs/windmill/issues/10973)) ([a0295b2](https://github.com/windmill-labs/windmill/commit/a0295b20c436fd3f2bd6a6d294ae3cee005391e8))
* keep braces inside string tool arguments out of JSON depth count ([#10965](https://github.com/windmill-labs/windmill/issues/10965)) ([3e3d2a6](https://github.com/windmill-labs/windmill/commit/3e3d2a636334146014926841949372083e6e8516))
* keep the instance user editor popover inside the viewport ([#10979](https://github.com/windmill-labs/windmill/issues/10979)) ([1901d31](https://github.com/windmill-labs/windmill/commit/1901d3193bfc6a9e29d0b7c5389fef44ff9d3687))
* meter WAC compute per segment, not the whole sleep ([#10985](https://github.com/windmill-labs/windmill/issues/10985)) ([5428710](https://github.com/windmill-labs/windmill/commit/54287102b22dd17903cdd4b48c5828875e5b9be4))
* name the extension to load when duckdb autoload hits the fence ([#10972](https://github.com/windmill-labs/windmill/issues/10972)) ([64b6798](https://github.com/windmill-labs/windmill/commit/64b679879936e2ddf4dbc2f90edbd56e3893bd83))
* **oauth:** show the account chooser on an explicit Google/Microsoft login ([#10961](https://github.com/windmill-labs/windmill/issues/10961)) ([9f7908e](https://github.com/windmill-labs/windmill/commit/9f7908e2622647388768b574083cc48a6e1990f1))
* patch critical CVEs in the worker image ([#10962](https://github.com/windmill-labs/windmill/issues/10962)) ([b100606](https://github.com/windmill-labs/windmill/commit/b100606da6a61f2dbcb24516363f43643bc917e3))
* render the MCP OAuth consent page without a workspace ([#10988](https://github.com/windmill-labs/windmill/issues/10988)) ([ebfac29](https://github.com/windmill-labs/windmill/commit/ebfac29096f12c4da2df45d5d82db83d352f3426))
* stand the WAC park down for a cancel that beat it to the row ([#10990](https://github.com/windmill-labs/windmill/issues/10990)) ([f977f5b](https://github.com/windmill-labs/windmill/commit/f977f5bf8b1ac70d3afbdc8ad6fcbe072cc51ebc))
## [1.803.0](https://github.com/windmill-labs/windmill/compare/v1.802.0...v1.803.0) (2026-09-03)
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COALESCE(dt.value->'database'->>'resource_type', 'unknown') AS \"kind!\",\n COUNT(*)::BIGINT AS \"count!\"\n FROM workspace_settings ws,\n LATERAL jsonb_each(ws.datatable->'datatables') dt\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n GROUP BY 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "0411a67eb9d88244fa654eda51123e16b5931c08c1073b09ab01dad205f161ed"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
"describe": {
"columns": [
{
@@ -15,16 +15,21 @@
},
{
"ordinal": 2,
"name": "guest_workspace_count!",
"name": "guest_jwt_count!",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "guest_enabled_workspace_count!",
"name": "guest_workspace_count!",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "guest_enabled_workspace_count!",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "workspace_count!",
"type_info": "Int4"
}
@@ -37,8 +42,9 @@
null,
null,
null,
null,
null
]
},
"hash": "8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60"
"hash": "06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue\n SET suspend = 0, suspend_until = NULL,\n started_at = coalesce(started_at, $2, now())\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "0e277240d2be50383ac53d844c71369067c41870be4561e1c26733ac30419801"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO guest_activity (email, workspace_id, day, jwt_entry)\n VALUES ($1, $2, CURRENT_DATE, true)\n ON CONFLICT (email, workspace_id, day)\n DO UPDATE SET jwt_entry = true, last_seen_at = now()\n WHERE NOT guest_activity.jwt_entry\n RETURNING 1 AS \"audited!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "audited!",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": [
null
]
},
"hash": "0fc900f73ef119e4c89186cf120938a298bfe29afe1fd7111340252877f8b86c"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = $2, suspend_until = now() + interval '14 day' WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Int4"
]
},
"nullable": []
},
"hash": "10af387fce25f6ea7af275e8e93b7ab1f2fc29a2ba79a39576551bdf66b592b6"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) FILTER (WHERE dt.value->>'migrations_enabled' = 'true')::BIGINT AS \"enabled!\",\n COUNT(*) FILTER (WHERE dt.value->>'migrations_enabled' = 'false')::BIGINT AS \"disabled!\",\n COUNT(*) FILTER (WHERE dt.value->>'migrations_enabled' IS NULL)::BIGINT AS \"unset!\"\n FROM workspace_settings ws,\n LATERAL jsonb_each(ws.datatable->'datatables') dt\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "enabled!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disabled!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "unset!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null
]
},
"hash": "209c96d522f9683b39f053707568f9943111e0bac7d4fb478300f0cd8b799f23"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*)::BIGINT AS \"total!\",\n COUNT(DISTINCT (workspace_id, datatable))::BIGINT AS \"datatables!\"\n FROM datatable_migrations",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "datatables!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "2105d37be923a445933c899bb31523709f837b7e1969b7364f70cc2a60cdd520"
}
@@ -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, 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) 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, 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 FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) FILTER (WHERE av.raw_app = false)::BIGINT AS \"low_code!\",\n COUNT(*) FILTER (WHERE av.raw_app = true)::BIGINT AS \"raw!\"\n FROM app a\n JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.policy->>'sandbox' = 'true'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "low_code!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "raw!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "9b727f03e74ea4a35146c9a339cda82104a9ee39bfbe5d932340a3c2c209c5d0"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET running = false, started_at = null WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a684f160d1a366c1928fef27c613e6e08f808f423c8f2d58b9c849aba7d176f5"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT usage_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM asset WHERE kind = 'datatable' AND usage_kind <> 'job'\n GROUP BY 1\n UNION ALL\n SELECT 'job_recent'::text, COUNT(DISTINCT (workspace_id, path))::BIGINT\n FROM asset\n WHERE kind = 'datatable' AND usage_kind = 'job'\n AND created_at > now() - interval '30 days'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "d3a6a27ece3b5d5071dd8074b8db6f369eceb079b31ca118cae23c3d15314590"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -167,6 +167,16 @@
"ordinal": 32,
"name": "guest_access_enabled",
"type_info": "Bool"
},
{
"ordinal": 33,
"name": "guest_jwt_public_key",
"type_info": "Text"
},
{
"ordinal": 34,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
}
],
"parameters": {
@@ -207,8 +217,10 @@
true,
true,
false,
false
false,
true,
true
]
},
"hash": "00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2"
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)\n UPDATE v2_job_queue q SET running = false, started_at = null\n FROM prev WHERE q.id = $1\n RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "int8",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd"
}
@@ -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, 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) 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, 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 FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e2eee8de61337b7d093f38e3e393e3620111119abf8dda4bde5b835ff934e4f1"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT canceled_by, canceled_reason,\n (extract(epoch FROM now() - started_at) * 1000)::bigint AS segment_ms\n FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "canceled_reason",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "segment_ms",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
true,
null
]
},
"hash": "e749663a4b9248a9d120f77e86fd3413ad3c0fbe8f3dca9aea11cb49fd4a28ef"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue\n SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Int4",
"Float8"
]
},
"nullable": []
},
"hash": "f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = 1, suspend_until = now() + make_interval(secs => $2) WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Float8"
]
},
"nullable": []
},
"hash": "f56c58fea9f27d2e55d33720e032808e90cb068d1048717f82992f476377cc20"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "guest_jwt_public_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f6fe63ef3518d2d1f321c2941bc59da15843f466c72159bf76712b124d7554b6"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "fbee9545c564f6fd611ca1a122cf420b03fd23304f78c382d6de14cb31bcd6b1"
}
+197 -194
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.803.0"
version = "1.804.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.803.0"
version = "1.804.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -367,6 +367,7 @@ aws-config.workspace = true
aws-credential-types.workspace = true
hmac.workspace = true
hex.workspace = true
jsonwebtoken = { workspace = true }
[workspace.dependencies]
@@ -599,6 +600,8 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
const-str = "0.5"
constant_time_eq = "0.3.1"
rsa = "^0"
spki = { version = "0.7", features = ["pem"] }
pkcs1 = "0.7"
aes-gcm = "0.10.3"
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
once_cell = "1.17.1"
+2 -2
View File
@@ -104,7 +104,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
| T6 | Disclosure of secrets, resource credentials, and workspace encryption keys across the authorization boundary (AI proxy, MCP, caches, export); database read additionally yields plaintext instance-level `global_settings` secrets | remote_auth | EP6, EP14, EP13 | Secret variables, encryption keys, resource creds, global settings | critical | likely | partially_mitigated | RLS on `$var:`, cache scoping by caller, admin checks on export; per-workspace secret *variables* encrypted at rest, but `global_settings` is plaintext under the default DB secret backend | GHSA-jwg4-v3cj-rvfm, GHSA-8m2p-2crh-9h3w, GHSA-6635-6fch-v8px, GHSA-437f-725p-7w84, GHSA-f27g-j463-q85w (CVE-2026-26964), GHSA-j679-v6vj-jfxc, GHSA-6vrr-fq33-qpfp, 0ba128afe7, 7836a4e733, ff8e39c69b |
| T7 | Full instance compromise from insecure deployment defaults (dind control, default admin/`changeme`, exposed Postgres, publicly readable SUPERADMIN_SECRET) | remote_unauth | EP15 | All assets | critical | likely | partially_mitigated | first-time-setup warning on default admin; docs recommend hardening | GHSA-3vpp-vf62-wqp6, GHSA-24fr-44f8-fqwg (CVE-2026-29059), GHSA-6q36-5p3h-766j |
| T8 | Unauthenticated RCE via the Debugger WebSocket: `/ws_debug/*` exposed by the gateway/ingress with the debugger service as the auth boundary; signature gate was bypassable via `program`-mode launches (read+exec an arbitrary server-side file path, never signed) even with signing on, and the WS handshake had no Origin check (CSWSH) | remote_unauth | EP15 | Worker host, all assets | critical | possible | partially_mitigated | `program`-mode launches now rejected when `REQUIRE_SIGNED_DEBUG_REQUESTS` is on (signing covers every launch, not just inline `code`); shipped `docker-compose` now defaults `REQUIRE_SIGNED_DEBUG_REQUESTS=true`; opt-in `DEBUG_ALLOWED_ORIGINS` allowlist rejects cross-origin handshakes. Residual: code default is secure but operators can still set `=false`; origin allowlist is opt-in | GHSA-725h-99vx-9xr4 |
| T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) |
| T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override; release images (`v*` tags) keyless-signed with cosign, with per-platform SPDX SBOMs embedded at build time (covered by the signed index digest) + SLSA provenance (GitHub artifact attestations) | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) |
| T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 |
| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, S3 download content-type, or a script-chosen `text/html` content type on `run_wait_result` / sync HTTP-route responses (GET-reachable with the `SameSite=Lax` session cookie) | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads and on every `result_to_response` composite result (inserted after `wm_headers`; hop-by-hop names such as `Connection` rejected so a proxy cannot strip them) | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0, WIN-2471 |
| T12 | Webhook authentication bypass / signature replay forges trigger invocations and approvals | remote_unauth | EP3 | Job execution integrity, approvals | high | likely | partially_mitigated | HMAC verification on some triggers; signing-oracle fix | GHSA-jw8c-h45c-xpjw, GHSA-hh9x-rcf8-xjr2, GHSA-q9g3-q6fj-hc2x, GHSA-8jc4-wj2p-2vmp, ab2a15b2a8 |
@@ -169,4 +169,4 @@ check.
| Canonicalize + confine all file-path inputs to a base dir and never follow symlinks in log/file readers | T13 | yes | S |
| Mask secrets at the log sink and keep secrets out of worker process env (`/proc`) — pass via files/pipes scrubbed after use | T15 | partial | M |
| Add global rate limiting and per-tenant resource/queue quotas at the edge | T16, T18 | partial | M |
| Pin and integrity-verify hub scripts and CI actions; SBOM + automated base-image CVE scanning in release | T9 | partial | M |
| Pin and integrity-verify hub scripts and CI actions; SBOM + automated base-image CVE scanning in release — release images now cosign-signed with SBOM + SLSA provenance attestations; remaining: CI action SHA-pinning, hub-script integrity, rhel/rpi images | T9 | partial | M |
+1 -1
View File
@@ -1 +1 @@
fb1c5c109846d6c47aff70ab6cc631f4fd773678
d33ea730c550cdbc7d050aeb6d40dcef3d134e07
@@ -0,0 +1,5 @@
ALTER TABLE guest_activity DROP COLUMN jwt_entry;
ALTER TABLE workspace_settings
DROP CONSTRAINT workspace_settings_guest_jwt_one_key,
DROP COLUMN guest_jwt_public_key,
DROP COLUMN guest_jwt_jwks_url;
@@ -0,0 +1,15 @@
-- A second way in for a guest: a JWT minted by the embedding customer's own backend and
-- verified against a key the workspace admin configured. One key shape per workspace,
-- a PEM public key or a JWKS URL, never both: a token is verified against exactly one
-- source, and two would make "which one refused it" undiagnosable.
ALTER TABLE workspace_settings
ADD COLUMN guest_jwt_public_key TEXT,
ADD COLUMN guest_jwt_jwks_url TEXT,
ADD CONSTRAINT workspace_settings_guest_jwt_one_key
CHECK (guest_jwt_public_key IS NULL OR guest_jwt_jwks_url IS NULL);
-- Whether the guest came in on a JWT that day (as opposed to, or as well as, an
-- identity-provider sign-in). The seat telemetry reports the two entries apart, since
-- an app-only user routed through a guest JWT is one that `jwt_ext_` would have counted.
ALTER TABLE guest_activity
ADD COLUMN jwt_entry BOOLEAN NOT NULL DEFAULT false;
+8 -2
View File
@@ -15,13 +15,19 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"userinfo_url": "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
"scopes": ["https://www.googleapis.com/auth/userinfo.email"]
"scopes": ["https://www.googleapis.com/auth/userinfo.email"],
"extra_params": {
"prompt": "select_account"
}
},
"microsoft": {
"auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"userinfo_url": "https://graph.microsoft.com/oidc/userinfo",
"scopes": ["openid", "profile", "email"]
"scopes": ["openid", "profile", "email"],
"extra_params": {
"prompt": "select_account"
}
},
"jumpcloud": {
"auth_url": "https://oauth.id.jumpcloud.com/oauth2/auth",
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.803.0"
version = "1.804.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.803.0"
version = "1.804.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+2 -2
View File
@@ -112,7 +112,7 @@ folder_permission_history: id(bigint), workspace_id(char), folder_name(char), ch
FK: (workspace_id, folder_name) -> folder(workspace_id, name)
gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[])
global_settings: name(char), value(jsonb), updated_at(ts)
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz)
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz), jwt_entry(bool)
group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb)
FK: (workspace_id) -> workspace(id)
group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char)
@@ -225,7 +225,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)
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)
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)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
+17 -2
View File
@@ -614,8 +614,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
Ok(())
}
/// Renaming a workspace copies its settings; the guest switch must travel with them,
/// or the rename silently shuts every guest app of the workspace.
/// Renaming a workspace copies its settings; the guest switch and the guest JWT key must
/// travel with them, or the rename silently shuts every guest app or drops the key.
#[sqlx::test(fixtures("base"))]
async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -629,6 +629,11 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
)
.execute(&db)
.await?;
sqlx::query(
"UPDATE workspace_settings SET guest_jwt_public_key = 'test-pem-key' WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
@@ -645,6 +650,16 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
.fetch_one(&db)
.await?;
assert!(enabled, "the guest switch travels with the workspace");
let jwt_key: Option<String> = sqlx::query_scalar(
"SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
)
.fetch_one(&db)
.await?;
assert_eq!(
jwt_key.as_deref(),
Some("test-pem-key"),
"the guest JWT key travels with the workspace"
);
let moved: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace-2')
AND NOT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace')",
+128
View File
@@ -0,0 +1,128 @@
//! The guest allowance reached through a guest JWT (`jwt_guest_`). Its own binary
//! because `set_plan` flips a process-global license key, which a test sharing the
//! process could not tolerate (see `app_guest_allowance.rs`).
//!
//! Users from the `base` fixture:
//! test-user (admin, token SECRET_TOKEN)
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::FREE_GUESTS_PER_WINDOW;
use windmill_test_utils::*;
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
const APP_PATH: &str = "u/test-user/guest_app";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Community and Pro are capped, Enterprise is metered. Only a build with both
/// `private` and `enterprise` can meter; every other build is capped whatever this says.
fn set_plan(pro: bool) {
#[cfg(feature = "private")]
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(
if pro { "test_pro" } else { "" }.to_string(),
));
let _ = pro;
}
const JWT_PUB: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzAfqyCh34iYOCW0vg4ejq/zzJlzL\nSZScjnVyPjLGTapEwo4gc6/y1Yudd/v54wKh0OdfTfzAKMPWx/2NWx/ugg==\n-----END PUBLIC KEY-----\n";
const JWT_PRIV: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgu27S2DbSwUh8BmQb\n/i4/VhNdoXV7PJekhnoceMULYLihRANCAATMB+rIKHfiJg4JbS+Dh6Or/PMmXMtJ\nlJyOdXI+MsZNqkTCjiBzr/LVi513+/njAqHQ519N/MAow9bH/Y1bH+6C\n-----END PRIVATE KEY-----\n";
fn guest_jwt(email: &str) -> String {
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
let exp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let claims = json!({
"email": email,
"workspace_id": "test-workspace",
"app_path": APP_PATH,
"exp": exp,
});
let jwt = encode(
&Header::new(Algorithm::ES256),
&claims,
&EncodingKey::from_ec_pem(JWT_PRIV.as_bytes()).unwrap(),
)
.unwrap();
format!("jwt_guest_{jwt}")
}
/// A JWT guest is subject to the same allowance as a signed-in one. Past the cap on a
/// capped instance, a stranger's JWT is refused (the auth arm returns 401; the visitor
/// message is only logged, since the arm cannot carry it), while a guest already in the
/// window is let back in.
#[sqlx::test(fixtures("base"))]
async fn a_guest_jwt_is_capped_like_a_signed_in_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
authed(
client().post(format!("{ws}/workspaces/edit_guest_access")),
ADMIN_TOKEN,
)
.json(&json!({ "guest_access_enabled": true }))
.send()
.await?;
let resp = authed(
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
ADMIN_TOKEN,
)
.json(&json!({ "public_key": JWT_PUB }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP_PATH,
"summary": "Guest app",
"value": {},
"policy": { "execution_mode": "guest", "triggerables_v2": {} }
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
// The whole allowance, used today (g1..gN).
sqlx::query(
"INSERT INTO guest_activity (email, workspace_id, day)
SELECT 'g' || i || '@example.com', 'test-workspace', CURRENT_DATE
FROM generate_series(1, $1) AS i",
)
.bind(FREE_GUESTS_PER_WINDOW)
.execute(&db)
.await?;
set_plan(true);
let resp = authed(
client().get(format!("{ws}/users/whoami")),
&guest_jwt("stranger@example.com"),
)
.send()
.await?;
assert_eq!(resp.status(), 401, "a stranger's JWT is refused past the cap");
let resp = authed(
client().get(format!("{ws}/users/whoami")),
&guest_jwt("g1@example.com"),
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a returning guest's JWT is admitted: {}",
resp.text().await?
);
Ok(())
}
+494
View File
@@ -0,0 +1,494 @@
//! Tests for the guest JWT entry: a guest that enters through a JWT the embedding
//! customer's own backend mints and signs, with no identity-provider round-trip.
//!
//! The key is a per-workspace setting (a PEM public key here), and the token is
//! verified per request against it. A JWT guest is the same identity as a signed-in
//! guest: no `usr` row, no `password` row, no seat, confined to the one app its
//! `app_path` names. These tests pin what a token must carry to be honoured, and the
//! refusals that keep the door narrow: wrong workspace, wrong key, expired, a
//! symmetric algorithm, an email that already has an account, an app not in guest
//! mode, and the workspace switch off.
//!
//! The keys are fixed test vectors (EC P-256, PKCS8), so signing is deterministic and
//! needs no key generation at runtime.
// Built with these like the sibling guest-execution suite: the guest run executes as
// the publisher through EE on-behalf-of code. CI builds with them.
#![cfg(all(feature = "enterprise", feature = "private"))]
use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::Serialize;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
const APP_PATH: &str = "u/test-user/guest_app";
const GUEST_EMAIL: &str = "guest@example.com";
// A P-256 keypair the workspace verifies against (PUB1), and a second private key
// (PRIV2) that it does not, for the wrong-key refusal.
const PRIV1: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgu27S2DbSwUh8BmQb\n/i4/VhNdoXV7PJekhnoceMULYLihRANCAATMB+rIKHfiJg4JbS+Dh6Or/PMmXMtJ\nlJyOdXI+MsZNqkTCjiBzr/LVi513+/njAqHQ519N/MAow9bH/Y1bH+6C\n-----END PRIVATE KEY-----\n";
const PUB1: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzAfqyCh34iYOCW0vg4ejq/zzJlzL\nSZScjnVyPjLGTapEwo4gc6/y1Yudd/v54wKh0OdfTfzAKMPWx/2NWx/ugg==\n-----END PUBLIC KEY-----\n";
const PRIV2: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjyhWYyI2+z5zTT0B\neI9EuJJ7v0tcNXhvHrq9y2AG1LihRANCAAS40dEdO+tTffhGt4YQv0dStkd6VcWN\n+CHI9QqZAHAJMsNS3Ld+sZe2M6Of0CNR300QJtfp4UIdEVbXBCIxL1D0\n-----END PRIVATE KEY-----\n";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[derive(Serialize)]
struct Claims {
email: String,
workspace_id: String,
app_path: String,
exp: u64,
#[serde(skip_serializing_if = "Option::is_none")]
nbf: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
iat: Option<u64>,
}
impl Claims {
fn valid() -> Self {
Claims {
email: GUEST_EMAIL.to_string(),
workspace_id: "test-workspace".to_string(),
app_path: APP_PATH.to_string(),
exp: now() + 3600,
nbf: None,
iat: None,
}
}
}
/// Sign as a bearer (`jwt_guest_<jwt>`). `priv_pem`/`alg` let a test sign with the
/// wrong key or a refused algorithm.
fn bearer(claims: &Claims, priv_pem: &str, alg: Algorithm) -> String {
let key = match alg {
Algorithm::HS256 => EncodingKey::from_secret(b"a-shared-secret"),
_ => EncodingKey::from_ec_pem(priv_pem.as_bytes()).unwrap(),
};
let jwt = encode(&Header::new(alg), claims, &key).unwrap();
format!("jwt_guest_{jwt}")
}
async fn enable_guests(port: u16, ws: &str, on: bool) -> anyhow::Result<()> {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access"
)),
ADMIN_TOKEN,
)
.json(&json!({ "guest_access_enabled": on }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
async fn set_guest_jwt_pem(port: u16, ws: &str, pem: &str) -> anyhow::Result<()> {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_jwt_key"
)),
ADMIN_TOKEN,
)
.json(&json!({ "public_key": pem }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
fn app(path: &str, execution_mode: &str, sandbox: bool) -> serde_json::Value {
json!({
"path": path,
"summary": "App",
"value": {},
"policy": {
"execution_mode": execution_mode,
"sandbox": sandbox,
"triggerables_v2": {
"script/u/test-user/noop": { "static_inputs": {}, "one_of_inputs": {} }
}
}
})
}
async fn create_app(port: u16, ws: &str, v: serde_json::Value) -> anyhow::Result<()> {
let resp = authed(
client().post(format!("http://localhost:{port}/api/w/{ws}/apps/create")),
ADMIN_TOKEN,
)
.json(&v)
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
Ok(())
}
fn whoami(port: u16, ws: &str, token: &str) -> reqwest::RequestBuilder {
authed(
client().get(format!("http://localhost:{port}/api/w/{ws}/users/whoami")),
token,
)
}
/// A valid guest JWT opens its app, runs a component as the publisher, reads the run
/// back, reports `role: guest`, and leaves exactly one `guest_activity` row however
/// many requests it makes.
#[sqlx::test(fixtures("base"))]
async fn a_valid_guest_jwt_opens_its_app(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
let resp = authed(
client().post(format!("http://localhost:{port}/api/w/{ws}/scripts/create")),
ADMIN_TOKEN,
)
.json(&json!({
"path": "u/test-user/noop",
"summary": "",
"description": "",
"content": "echo 42",
"language": "bash",
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
// A distinct email: the activity write is deduplicated by a process-global cache
// keyed on email, workspace and day, and other tests in this binary share the
// guest email, so the count below is only this test's if its email is its own.
let mut claims = Claims::valid();
claims.email = "activity-guest@example.com".to_string();
let token = bearer(&claims, PRIV1, Algorithm::ES256);
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(resp.status(), 200, "guest JWT must authenticate");
let me: serde_json::Value = resp.json().await?;
assert_eq!(me["role"], json!("guest"), "must read as a guest");
assert_eq!(me["operator"], json!(true));
assert_eq!(me["is_admin"], json!(false));
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/{ws}/apps_u/execute_component/{APP_PATH}"
)),
&token,
)
.json(&json!({ "component": "a", "path": "script/u/test-user/noop", "args": {} }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let job_id = resp.text().await?;
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/{ws}/jobs_u/getupdate/{job_id}"
)),
&token,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"the guest that started the run must read it back: {}",
resp.text().await?
);
// Several requests, one row: the write is cached per email, workspace and day.
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM guest_activity WHERE email = $1 AND workspace_id = $2 AND jwt_entry",
)
.bind(&claims.email)
.bind(ws)
.fetch_one(&db)
.await?;
assert_eq!(count, 1, "a JWT guest must leave exactly one activity row");
Ok(())
}
/// The refusals that keep the door narrow. Each presents a bearer on the workspace's
/// own `whoami`, which the arm reaches only after every gate, so a 401 is the arm
/// saying no rather than a handler.
#[sqlx::test(fixtures("base"))]
async fn guest_jwt_refusals(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
create_app(port, ws, app("u/test-user/members_app", "publisher", false)).await?;
// Positive control: a token valid against this exact fixture is admitted. Without it a
// broken setup would 401 every bearer below and the whole suite would pass vacuously.
let control = whoami(port, ws, &bearer(&Claims::valid(), PRIV1, Algorithm::ES256))
.send()
.await?;
assert_eq!(control.status(), 200, "{}", control.text().await?);
// wrong workspace: the claim must name the route's workspace.
let mut c = Claims::valid();
c.workspace_id = "other-ws".to_string();
let wrong_ws = bearer(&c, PRIV1, Algorithm::ES256);
// wrong key: signed with a key the workspace does not hold.
let wrong_key = bearer(&Claims::valid(), PRIV2, Algorithm::ES256);
// expired, past the verifier's clock-skew leeway.
let mut c = Claims::valid();
c.exp = now() - 120;
let expired = bearer(&c, PRIV1, Algorithm::ES256);
// a symmetric algorithm is never accepted.
let hs256 = bearer(&Claims::valid(), PRIV1, Algorithm::HS256);
// an email that already has an account is refused, not downgraded.
let mut c = Claims::valid();
c.email = "test@windmill.dev".to_string();
let has_account = bearer(&c, PRIV1, Algorithm::ES256);
// an app not in guest mode.
let mut c = Claims::valid();
c.app_path = "u/test-user/members_app".to_string();
let not_guest_app = bearer(&c, PRIV1, Algorithm::ES256);
// an existing account addressed in a different case still counts as an account:
// the base fixture holds `test@windmill.dev`.
let mut c = Claims::valid();
c.email = "Test@Windmill.Dev".to_string();
let mixed_case_account = bearer(&c, PRIV1, Algorithm::ES256);
// a lifetime past the 24h cap, even with a valid signature.
let mut c = Claims::valid();
c.exp = now() + 25 * 3600;
let over_lifetime_cap = bearer(&c, PRIV1, Algorithm::ES256);
// an email with no `@` would become the guest's username and could be read as a
// `u/<user>` or `g/<group>` principal; refused.
let mut c = Claims::valid();
c.email = "group-admins".to_string();
let group_shaped_email = bearer(&c, PRIV1, Algorithm::ES256);
// an email longer than the `guest_activity.email` column: refused before auth, so a
// guest is never admitted without the activity row and audit event the count needs.
let mut c = Claims::valid();
c.email = format!("{}@example.com", "a".repeat(250));
let oversized_email = bearer(&c, PRIV1, Algorithm::ES256);
// an app_path carrying a scope metacharacter would widen the guest's scopes.
let mut c = Claims::valid();
c.app_path = "u/test-user/*".to_string();
let wildcard_app_path = bearer(&c, PRIV1, Algorithm::ES256);
// a valid, signed token past the length cap: without the cap it would deserialize into
// GuestJwtClaims (the extra claim ignored) and verify, so this pins the length check.
let mut payload = serde_json::to_value(Claims::valid()).unwrap();
payload["padding"] = serde_json::json!("a".repeat(9000));
let big_jwt = encode(
&Header::new(Algorithm::ES256),
&payload,
&EncodingKey::from_ec_pem(PRIV1.as_bytes()).unwrap(),
)
.unwrap();
let oversized_token = format!("jwt_guest_{big_jwt}");
// a repeated prefix must not strip down to a valid short token that verifies and is then
// cached under the full bearer key (trim_start_matches would; strip_prefix must not).
let repeated_prefix = format!(
"jwt_guest_{}",
bearer(&Claims::valid(), PRIV1, Algorithm::ES256)
);
for (label, token) in [
("wrong workspace", wrong_ws),
("wrong key", wrong_key),
("expired", expired),
("HS256", hs256),
("email with an account", has_account),
("app not in guest mode", not_guest_app),
("mixed-case account", mixed_case_account),
("over the 24h lifetime cap", over_lifetime_cap),
("group-shaped email", group_shaped_email),
("oversized email", oversized_email),
("wildcard app_path", wildcard_app_path),
("oversized token", oversized_token),
("repeated prefix", repeated_prefix),
] {
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(resp.status(), 401, "{label} must be refused");
}
Ok(())
}
/// The workspace switch gates a JWT guest exactly as it gates a signed-in one, at the
/// auth door, so turning guests off closes the JWT entry too.
#[sqlx::test(fixtures("base"))]
async fn guest_jwt_needs_the_workspace_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256);
// Switch off (the default): refused.
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(
resp.status(),
401,
"a JWT guest must be refused while guests are off"
);
// Switch on: through.
enable_guests(port, ws, true).await?;
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(
resp.status(),
200,
"with guests on, the JWT guest is admitted"
);
// Off again: closed on the next request.
enable_guests(port, ws, false).await?;
let resp = whoami(port, ws, &token).send().await?;
assert_eq!(
resp.status(),
401,
"turning guests off closes the JWT guest again"
);
Ok(())
}
/// A guest JWT is pinned to the workspace its claim names, so it authenticates on no
/// workspace-less route: the arm has no workspace to check the claim against.
#[sqlx::test(fixtures("base"))]
async fn guest_jwt_rejected_on_workspaceless_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", false)).await?;
let token = bearer(&Claims::valid(), PRIV1, Algorithm::ES256);
let resp = authed(
client().get(format!("http://localhost:{port}/api/users/tokens/list")),
&token,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"a guest JWT must not authenticate on a workspace-less route"
);
Ok(())
}
/// An embed token a JWT guest mints for a sandboxed app is capped at the JWT's own
/// expiry: a JWT has no token row, so the cap is carried through the auth cache. It
/// must not outlive the JWT, which is the guest's only revocation.
#[sqlx::test(fixtures("base"))]
async fn a_guest_jwt_derived_embed_token_is_capped(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = "test-workspace";
enable_guests(port, ws, true).await?;
set_guest_jwt_pem(port, ws, PUB1).await?;
create_app(port, ws, app(APP_PATH, "guest", true)).await?;
let secret: String = authed(
client().get(format!(
"http://localhost:{port}/api/w/{ws}/apps/secret_of/{APP_PATH}"
)),
ADMIN_TOKEN,
)
.send()
.await?
.text()
.await?;
let claims = Claims::valid();
let jwt_exp = claims.exp;
let token = bearer(&claims, PRIV1, Algorithm::ES256);
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/{ws}/apps_u/embed_token/{secret}"
)),
&token,
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let body: serde_json::Value = resp.json().await?;
let child_exp: chrono::DateTime<chrono::Utc> = body["expiration"]
.as_str()
.and_then(|e| e.parse().ok())
.expect("mint must return the token's expiration");
assert!(
child_exp.timestamp() as u64 <= jwt_exp,
"the derived embed token ({child_exp}) must not outlive the JWT (exp {jwt_exp})"
);
// And it resolves as a guest.
let embed = body["token"].as_str().expect("mint must return a token");
let resp = whoami(port, ws, embed).send().await?;
assert_eq!(resp.status(), 200);
let me: serde_json::Value = resp.json().await?;
assert_eq!(me["role"], json!("guest"));
Ok(())
}
/// A workspace with no guest key of its own falls back to the instance issuer
/// (`JWT_EXT_JWKS_URL`), so an operator running one issuer configures it once. Verified as a
/// guest here in CE; a full login from that issuer stays EE (`jwt_ext_`).
#[sqlx::test(fixtures("base"))]
async fn no_workspace_key_falls_back_to_the_instance_issuer(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::guest_jwt::{key_source, GuestJwtKeySource};
let url = "https://issuer.example.com/jwks.json";
unsafe { std::env::set_var("JWT_EXT_JWKS_URL", url) };
let src = key_source(&db, "test-workspace").await;
unsafe { std::env::remove_var("JWT_EXT_JWKS_URL") };
assert!(
matches!(src?, Some(GuestJwtKeySource::JwksUrl(u)) if u == url),
"no workspace key falls back to the instance issuer"
);
Ok(())
}
+1
View File
@@ -25,6 +25,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
@@ -178,6 +178,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Guards what `suspend_wac_parent` promises: the `started_at` invariant documented on
//! it, the segment length it hands back for metering, and that it stands down for a
//! cancel already on the row.
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_worker::wac_executor::{suspend_wac_parent, WacPark};
#[sqlx::test]
async fn wac_suspend_clears_started_at(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at) \
VALUES ($1, 'test-workspace', now(), true, now() - interval '4 days')",
)
.bind(job_id)
.execute(&db)
.await?;
let mut tx = db.begin().await?;
let WacPark::Parked(segment_ms) =
suspend_wac_parent(&mut tx, &job_id, "test-workspace", 1, 3600.0).await?
else {
panic!("an uncancelled parent must park");
};
tx.commit().await?;
// The segment is what gets billed, so it must be the run that just ended, measured
// from the pull — not the park ahead of it, and not zero.
let four_days_ms = 4 * 24 * 3600 * 1000;
assert!(
segment_ms.is_some_and(|ms| (ms - four_days_ms).abs() < 60_000),
"expected the ended segment (~{four_days_ms}ms), got {segment_ms:?}"
);
let (started_at, running, suspend, suspend_until): (
Option<chrono::DateTime<chrono::Utc>>,
bool,
i32,
Option<chrono::DateTime<chrono::Utc>>,
) = sqlx::query_as(
"SELECT started_at, running, suspend, suspend_until FROM v2_job_queue WHERE id = $1",
)
.bind(job_id)
.fetch_one(&db)
.await?;
assert_eq!(
started_at, None,
"a parked parent must not carry the previous segment's started_at"
);
assert_eq!(suspend, 1);
assert!(suspend_until.is_some());
assert!(
running,
"running stays true so the normal pull query skips the parked row"
);
Ok(())
}
/// A soft cancel sets `canceled_by` and `suspend = 0` and leaves acting on it to the next
/// pull. Parking over that holds the row until `suspend_until` — a whole day on a
/// `sleep(86400)` — so the park has to stand down and let the job complete instead.
#[sqlx::test]
async fn wac_suspend_stands_down_for_a_cancel(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job_queue \
(id, workspace_id, scheduled_for, running, started_at, suspend, canceled_by, canceled_reason) \
VALUES ($1, 'test-workspace', now(), true, now() - interval '30 seconds', 0, 'alice', 'no longer needed')",
)
.bind(job_id)
.execute(&db)
.await?;
let mut tx = db.begin().await?;
let parked = suspend_wac_parent(&mut tx, &job_id, "test-workspace", 1, 86400.0).await?;
tx.commit().await?;
match &parked {
WacPark::Cancelled(cancel) => {
assert_eq!(cancel.username.as_deref(), Some("alice"));
assert_eq!(cancel.reason.as_deref(), Some("no longer needed"));
}
other => panic!("a cancelled parent must not park, got {other:?}"),
}
let (suspend, suspend_until, started_at): (
i32,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::DateTime<chrono::Utc>>,
) = sqlx::query_as(
"SELECT suspend, suspend_until, started_at FROM v2_job_queue WHERE id = $1",
)
.bind(job_id)
.fetch_one(&db)
.await?;
assert_eq!(suspend, 0, "the cancel's suspend = 0 must survive");
assert_eq!(
suspend_until, None,
"a suspend_until would hold the row back for the whole park window"
);
assert!(
started_at.is_some(),
"the segment ran, so its start must stay for the completion's duration"
);
Ok(())
}
+1
View File
@@ -1094,6 +1094,7 @@ async fn test_privilege_gates_reject_a_job_token_directly(
token_prefix: None,
read_only: false,
job_id,
credential_expiry: None,
}
}
@@ -35,6 +35,7 @@ fn outsider() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+247
View File
@@ -175,6 +175,18 @@ impl AuthCache {
if is_no_auth() {
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
}
// Reject an oversized guest bearer before the cache key is built from it: the key
// copies and hashes the whole token, so the cap should bound that work too. Log it
// like the other guest refusals, since get_opt_job_authed turns None into a bare 401.
if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX)
&& token.len() > windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
{
tracing::error!(
"guest JWT refused: bearer is longer than {} bytes",
windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
);
return None;
}
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
@@ -216,6 +228,111 @@ impl AuthCache {
None
}
}
_ if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX) => {
// A workspace-less route never accepts a guest JWT: the identity is
// pinned to the workspace its claim names, like a DB guest session.
let Some(w_id) = w_id.as_deref() else {
return None;
};
// Strip exactly one prefix: `trim_start_matches` would strip repeated prefixes,
// so `jwt_guest_jwt_guest_<jwt>` would reduce to a valid token that verifies and
// is then cached under the full, non-canonical bearer key.
let jwt = token
.strip_prefix(windmill_common::guest_jwt::BEARER_PREFIX)
.unwrap_or(token);
let claims =
match windmill_common::guest_jwt::verify_for_workspace(&self.db, w_id, jwt)
.await
{
Ok(c) => c,
Err(e) => {
tracing::error!("guest JWT auth error for {w_id}: {e:#}");
return None;
}
};
// The workspace switch, the instance switch and the app being in guest
// mode, in one answer (guest_app_admits). The door re-reads the switches
// and the no-account rule per request through the sentinel below
// (guest_session_stands), so turning any of them off stops a cached JWT
// session on its next call.
match windmill_common::workspaces::guest_app_admits(
&self.db,
w_id,
&claims.app_path,
)
.await
{
Ok(true) => {}
Ok(false) => return None,
Err(e) => {
tracing::error!("guest JWT admit check failed for {w_id}: {e:#}");
return None;
}
}
// Resolve on the lowercased email: accounts are stored lowercased, so a
// mixed-case claim would otherwise slip past the no-account gate and
// resolve an account holder to a guest, and split the activity rows the
// seat count reads.
let email = claims.email.to_lowercase();
// A guest is someone with no account at all; an account holder is refused,
// never downgraded (the same rule as the signed-in guest mint).
match windmill_common::users::has_any_account(&self.db, &email).await {
Ok(false) => {}
Ok(true) => return None,
Err(e) => {
tracing::error!("guest JWT account check failed: {e:#}");
return None;
}
}
// The instance allowance, checked and recorded transactionally. A stranger
// past the cap on a capped instance is refused here; a returning guest
// always passes. Recording an account holder is avoided by the check above.
if !admit_and_record_guest_jwt(&self.db, w_id, &email, &claims.app_path).await {
return None;
}
// guest_session_scopes already carries the sentinel, and it is the whole
// grant; a JWT has no label, so the sentinel is what governs it. It also
// re-checks the path holds no scope metacharacter (verify already did).
let scopes = match crate::scopes::guest_session_scopes(&claims.app_path) {
Ok(s) => Some(s),
Err(e) => {
tracing::error!("guest JWT app_path cannot be scoped for {w_id}: {e:#}");
return None;
}
};
// The JWT's own expiry caps a token minted from this session. The auth
// cache entry itself is capped far shorter (GUEST_JWT_CACHE_TTL) so a
// rotated or cleared key stops the session on re-verification, within
// minutes, rather than only at exp (up to 24h away).
let credential_expiry =
chrono::Utc.timestamp_nanos(claims.exp as i64 * 1_000_000_000);
let cache_expiry = credential_expiry.min(chrono::Utc::now() + GUEST_JWT_CACHE_TTL);
let authed = ApiAuthed {
username: email.clone(),
email,
is_admin: false,
is_operator: true,
groups: vec![],
folders: vec![],
scopes,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
job_id: None,
credential_expiry: Some(credential_expiry),
};
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: cache_expiry,
job_id: None,
},
);
Some(OptJobAuthed { authed, job_id: None })
}
_ if token.starts_with("jwt_") => {
let jwt_token = token.trim_start_matches("jwt_");
@@ -249,6 +366,7 @@ impl AuthCache {
token_prefix: claims.audit_span,
read_only: false,
job_id: None,
credential_expiry: None,
};
// Fail closed: a `job_id` claim that does not parse must reject
// the token rather than resolve to `None`, which would clear the
@@ -363,6 +481,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
} else {
tracing::warn!(
@@ -416,6 +535,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
} else {
tracing::warn!(
@@ -494,6 +614,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
None if super_admin => {
@@ -518,6 +639,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
}),
Err(e) => {
tracing::error!(
@@ -555,6 +677,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
None => None,
@@ -574,6 +697,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
}
@@ -612,6 +736,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
job_id: None,
credential_expiry: None,
};
Some(OptJobAuthed { authed, job_id: None })
} else {
@@ -622,6 +747,127 @@ impl AuthCache {
}
}
/// How long a guest JWT resolves from the auth cache before the arm re-runs (and
/// re-reads the key). A guest JWT is not revocable except by the workspace switch or
/// by rotating the key, so the entry must be short enough that a rotated key bites
/// soon, unlike a normal token whose row can be deleted. Also what makes the
/// day-keyed activity dedupe below reachable across a midnight.
const GUEST_JWT_CACHE_TTL: chrono::Duration = chrono::Duration::minutes(5);
/// A refused JWT (a stranger past the allowance) is remembered this long so a replayed
/// bearer does not take the instance-wide allowance advisory lock on every request.
/// Short, so a stranger admitted once the window frees is re-checked soon.
const GUEST_JWT_REFUSED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
lazy_static::lazy_static! {
// One `guest_activity` upsert and one `users.login_guest` audit per email,
// workspace and day: the arm re-runs every GUEST_JWT_CACHE_TTL, and neither the
// seat scan nor the audit trail wants a write each time. LRU-bounded; the day is in
// the key, so a new day writes again.
static ref GUEST_JWT_ACTIVITY_CACHE: Cache<String, ()> = Cache::new(2000);
static ref GUEST_JWT_REFUSED_CACHE: Cache<String, std::time::Instant> = Cache::new(2000);
}
/// Admit a JWT guest against the instance allowance and record today's activity, in one
/// transaction so the advisory lock in `guest_admission` spans the count check and the
/// row that changes it. Returns false when the allowance refuses the email or on a DB
/// error, both of which deny the guest. Cached per email, workspace and day: a bearer
/// replayed every request runs this at most once a day, and a refused one is remembered
/// briefly so it does not re-take the allowance lock. `email` is already lowercased.
async fn admit_and_record_guest_jwt(db: &DB, w_id: &str, email: &str, app_path: &str) -> bool {
let cache_key = format!("{email}|{w_id}|{}", chrono::Utc::now().date_naive());
if GUEST_JWT_ACTIVITY_CACHE.get(&cache_key).is_some() {
return true;
}
if GUEST_JWT_REFUSED_CACHE
.get(&cache_key)
.is_some_and(|at| at.elapsed() < GUEST_JWT_REFUSED_TTL)
{
return false;
}
let mut tx = match db.begin().await {
Ok(tx) => tx,
Err(e) => {
tracing::error!("guest JWT tx begin failed for {w_id}: {e:#}");
return false;
}
};
// The allowance and the row that changes it, in one transaction: guest_admission
// takes a transaction-scoped advisory lock, so the count check and the insert cannot
// race two strangers past the cap. Only a real allowance refusal is negative-cached;
// a transient DB error denies this request but must not lock the email out for 30s.
match windmill_common::workspaces::guest_admission(&mut *tx, email).await {
Ok(()) => {}
Err(e @ windmill_common::error::Error::PermissionDenied(_)) => {
// The guest hits a bare 401 (the reason must not leak to an unauthenticated caller);
// warn so an admin sees the cap in logs, since it is the actionable signal here.
tracing::warn!("guest JWT refused (guest allowance) for {w_id}: {e:#}");
GUEST_JWT_REFUSED_CACHE.insert(cache_key, std::time::Instant::now());
return false;
}
Err(e) => {
tracing::error!("guest JWT allowance check failed for {w_id}: {e:#}");
return false;
}
}
// The conditional `WHERE NOT jwt_entry` flips the flag only on its false-to-true
// transition, so the upsert returns a row exactly once per email per day: on the
// fresh insert, or on the first JWT after an identity-provider sign-in created
// today's row with `jwt_entry = false`. The audit is gated on that, decided
// atomically by the conflicting tuple, so concurrent first requests (a metered
// instance takes no advisory lock) audit at most once.
let first_jwt = sqlx::query_scalar!(
r#"INSERT INTO guest_activity (email, workspace_id, day, jwt_entry)
VALUES ($1, $2, CURRENT_DATE, true)
ON CONFLICT (email, workspace_id, day)
DO UPDATE SET jwt_entry = true, last_seen_at = now()
WHERE NOT guest_activity.jwt_entry
RETURNING 1 AS "audited!""#,
email,
w_id,
)
.fetch_optional(&mut *tx)
.await;
let first_jwt = match first_jwt {
Ok(v) => v.is_some(),
Err(e) => {
tracing::error!("recording guest JWT activity for {w_id}: {e:#}");
return false;
}
};
if let Err(e) = tx.commit().await {
tracing::error!("guest JWT tx commit failed for {w_id}: {e:#}");
return false;
}
GUEST_JWT_ACTIVITY_CACHE.insert(cache_key, ());
// Audit last, best-effort, on its own connection: the EE writer swallows an
// `audit_partitioned` failure but that failing statement still aborts the
// transaction it runs in, so auditing before the commit would let the whole
// activity row roll back while this returned success, admitting an uncounted guest.
if first_jwt {
let author = windmill_common::audit::AuditAuthor {
email: email.to_string(),
username: email.to_string(),
username_override: None,
token_prefix: None,
};
if let Err(e) = windmill_audit::audit_oss::audit_log(
db,
&author,
"users.login_guest",
windmill_audit::ActionKind::Create,
w_id,
Some(app_path),
Some([("entry", "jwt")].into()),
)
.await
{
tracing::error!("auditing guest JWT login for {w_id}: {e:#}");
}
}
true
}
pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
@@ -822,6 +1068,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+7
View File
@@ -78,6 +78,11 @@ pub struct ApiAuthed {
/// member can point at a superadmin, so it must never be trusted as a global
/// superadmin (`require_super_admin`), GHSA-hfh4-cx4h-3fcr.
pub job_id: Option<uuid::Uuid>,
/// When this credential itself expires, if it carries its own expiry rather than a
/// token row. Set for a guest JWT (its `exp`): a token minted from it is capped at
/// this, since the JWT's expiry is a guest's only revocation and there is no row to
/// look the limit up in. `None` for every credential whose limit lives in `token`.
pub credential_expiry: Option<chrono::DateTime<chrono::Utc>>,
}
impl ApiAuthed {
@@ -165,6 +170,7 @@ impl From<Authed> for ApiAuthed {
token_prefix: value.token_prefix,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
}
@@ -1074,6 +1080,7 @@ pub async fn fetch_api_authed_from_permissioned_as(
token_prefix: authed.token_prefix,
read_only: false,
job_id: None,
credential_expiry: None,
};
API_AUTHED_CACHE.insert(
+27
View File
@@ -793,6 +793,33 @@ pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
scopes
}
/// Scopes a guest session carries. The broad-looking reads are narrowed to a route
/// allowlist by the sentinel (`guest_route_denied`), plus the two path-scoped app
/// grants. A guest has no `usr` row, so this list is the whole of what it can do. The
/// single source both the mint (a signed-in guest) and the JWT auth arm build from.
///
/// The sentinel here only narrows. A signed-in guest is made one by the server-minted
/// label; a JWT guest has no label, so for it the sentinel is what governs.
pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Vec<String>> {
// The path is spliced into a scope, whose grammar reserves `:`, `,`, `*` and a leading
// `/`; app paths may otherwise carry spaces and `@`, so guard only those reserved chars.
if !windmill_common::auth::is_scope_literal_path(app_path) {
return Err(windmill_common::error::Error::BadRequest(format!(
"app path {app_path} is empty or cannot be scoped: `:`, `,` and `*` are reserved \
in scopes, and a leading `/` never matches a route"
)));
}
Ok(vec![
GUEST_SENTINEL.to_string(),
"jobs:read".to_string(),
"resources:run".to_string(),
"users:read".to_string(),
"folders:read".to_string(),
format!("apps:read:{app_path}"),
format!("apps:run:{app_path}"),
])
}
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
/// to narrow the declared scopes to what the viewer's prompt promised.
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
@@ -15,6 +15,12 @@ use windmill_test_utils::*;
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
/// A bun lock the executor accepts without installing anything: no dependencies
/// in the `package.json` half, `<empty>` for the `bun.lock` half. The empty
/// string is not a substitute: a lock carrying no `//bun.lock` separator is
/// rejected at run time.
const EMPTY_BUN_LOCK: &str = "{}\n//bun.lock\n<empty>";
/// Echoes the two halves of the event separately, so the assertions can tell
/// which one a value arrived in.
const PREPROCESSOR_SCRIPT: &str = r#"
@@ -84,7 +90,7 @@ async fn test_mcp_preprocessor_receives_the_callers_headers(
"description": "",
"content": PREPROCESSOR_SCRIPT,
"language": "bun",
"lock": "",
"lock": EMPTY_BUN_LOCK,
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
@@ -101,13 +107,14 @@ async fn test_mcp_preprocessor_receives_the_callers_headers(
resp.text().await.unwrap_or_default()
);
// A script counts as deployed once it has a lock, which normally arrives from
// a dependency job. Planting an empty one keeps the test to the path under
// test instead of a bun resolution whose timing it does not control.
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
.bind(SCRIPT_PATH)
.execute(&db)
.await?;
// A supplied lock queues no dependency job, so the version is deployed (hence
// listable and runnable) as soon as the create returns.
let queued: i64 = sqlx::query_scalar(
"SELECT count(*) FROM v2_job_queue WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
assert_eq!(queued, 0, "the supplied lock must queue no dependency job");
let tools = mcp_post(
port,
@@ -63,6 +63,7 @@ fn test_authed() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+5 -39
View File
@@ -2938,31 +2938,6 @@ lazy_static::lazy_static! {
.unwrap_or(8 * 60 * 60);
}
/// Scopes a guest session carries. Mirrors `APP_EMBED_SCOPES` — the same broad-looking
/// reads narrowed to a route allowlist by the sentinel (`guest_route_denied`) — plus the
/// two path-scoped app grants minted per app. With no ACL of its own, this list is the
/// whole of what a guest can do.
///
/// The `guest` sentinel here only narrows. What makes the session a guest at all is the
/// server-minted label ([`windmill_common::auth::GUEST_SESSION_LABEL`]).
fn guest_session_scopes(app_path: &str) -> Result<Vec<String>> {
if !windmill_common::auth::is_scope_literal_path(app_path) {
return Err(Error::BadRequest(format!(
"app path {app_path} cannot be scoped: `:`, `,` and `*` are reserved in scopes, \
and a leading `/` never matches a route"
)));
}
Ok(vec![
windmill_api_auth::scopes::GUEST_SENTINEL.to_string(),
"jobs:read".to_string(),
"resources:run".to_string(),
"users:read".to_string(),
"folders:read".to_string(),
format!("apps:read:{app_path}"),
format!("apps:run:{app_path}"),
])
}
/// Mint a browser session for someone the identity provider authenticated who is a
/// member of no workspace, so they can open one guest-mode app. Writes no `password`
/// and no `usr` row: that absence is what keeps a guest off every seat counter, so
@@ -2994,20 +2969,11 @@ pub async fn create_guest_session_token<'c>(
} else {
Some(&token)
};
let scopes = guest_session_scopes(app_path)?;
let scopes = windmill_api_auth::scopes::guest_session_scopes(app_path)?;
// No account at all (see `ExecutionMode::Guest`): a deactivated `password` row
// counts, since the sign-in path's own lookup filters on `disabled = false` and a
// SCIM-offboarded account would otherwise read as absent; so does a `usr` row in
// any workspace, which is what a service account has instead of a password.
let has_account: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
)
.bind(email)
.fetch_one(&mut **tx)
.await?;
if has_account {
// No account at all (see `has_any_account`): an account holder is refused a guest
// session, never handed a second, cheaper identity. The same helper the JWT arm uses.
if windmill_common::users::has_any_account(&mut **tx, email).await? {
return Err(Error::NotAuthorized(
"an existing account cannot hold a guest session".to_string(),
));
@@ -3061,7 +3027,7 @@ pub async fn create_guest_session_token<'c>(
ActionKind::Create,
w_id,
Some(app_path),
None,
Some([("entry", "idp")].into()),
)
.await?;
@@ -416,6 +416,16 @@ async fn run_datatable_migrations(
let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?;
// How the user scoped the run, for the counter emitted on the first migration
// that lands below.
let scope = if query.only.is_some() {
"only"
} else if query.up_to.is_some() {
"up_to"
} else {
"all"
};
let mut applied = Vec::new();
for m in migrations {
if let Some(only) = query.only {
@@ -453,6 +463,14 @@ async fn run_datatable_migrations(
))
})?;
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
// One event per run that moved the data table forward, emitted on the
// first migration that lands rather than after the loop: a later one
// failing returns early, and that run still advanced the data table. A
// run with nothing pending stays uncounted — it is the common outcome of
// opening the list and would drown out the runs that did something.
if applied.len() == 1 {
windmill_common::feature_usage::log_feature_usage("datatable", "migration_run", scope);
}
}
Ok(Json(RunDatatableMigrationsResult { applied }))
@@ -594,6 +612,12 @@ async fn rollback_datatable_migrations(
))
})?;
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_rollback",
if query.only.is_some() { "only" } else { "last" },
);
Ok(Json(RollbackDatatableMigrationsResult {
rolled_back: vec![RolledBackMigration { version, name: definition.name }],
}))
@@ -824,6 +848,8 @@ async fn enable_datatable_migrations(
)
.await?;
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "on");
Ok(format!(
"Enabled migrations for data table {datatable_name}"
))
@@ -892,6 +918,8 @@ async fn disable_datatable_migrations(
.await?;
}
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "off");
Ok(format!(
"Disabled migrations for data table {datatable_name} and deleted its migrations"
))
@@ -1134,6 +1162,8 @@ async fn create_datatable_migration(
)
.await?;
windmill_common::feature_usage::log_feature_usage("datatable", "migration_created", "manual");
Ok(Json(DatatableMigration {
datatable: datatable_name,
timestamp,
@@ -1371,6 +1401,20 @@ async fn upsert_datatable_migration(
)
.await?;
// An unchanged re-push is not counted: `wmill sync push` sends every migration
// on every sync, so counting those would swamp the definitions people write.
if !unchanged {
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_created",
if existing.is_none() {
"synced"
} else {
"edited"
},
);
}
Ok(format!(
"Upserted migration {} in {}",
payload.timestamp, datatable_name
@@ -1477,6 +1521,12 @@ async fn generate_initial_datatable_migration(
)
.await?;
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_created",
"initial_snapshot",
);
Ok(Json(DatatableMigration {
datatable: datatable_name,
timestamp,
@@ -12,10 +12,10 @@ use windmill_api_auth::{
};
use windmill_api_users::users::WorkspaceInvite;
use windmill_common::email_oss::send_email_if_possible;
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
use windmill_common::webhook::WebhookShared;
use windmill_common::{BASE_URL, DB};
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
use axum::{
extract::{Extension, Path, Query},
@@ -152,6 +152,7 @@ pub fn workspaced_service() -> Router {
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
.route("/edit_default_app", post(edit_default_app))
.route("/edit_guest_access", post(edit_guest_access))
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
.route("/guest_usage", get(get_guest_usage))
.route("/default_app", get(get_default_app))
.route(
@@ -322,6 +323,14 @@ pub struct WorkspaceSettings {
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
/// own `execution_mode: guest` is inert while this is off.
pub guest_access_enabled: bool,
/// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most
/// one (a DB CHECK enforces it). Public material, not a secret, so it is admin-
/// readable here. `None`/`None` falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
/// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch.
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_jwks_url: Option<String>,
}
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
@@ -1082,7 +1091,9 @@ async fn get_settings(
success_handler,
public_app_execution_limit_per_minute,
error_handler_fallback_to_instance_alerts,
guest_access_enabled
guest_access_enabled,
guest_jwt_public_key,
guest_jwt_jwks_url
FROM
workspace_settings
WHERE
@@ -3513,6 +3524,9 @@ async fn edit_datatable_config(
// Migrations opt-in is owned by the enable/disable endpoints, not this config
// form: preserve each existing data table's flag, and default brand-new data
// tables to enabled.
// Counted here rather than after the write because this is where a rename is
// still distinguishable from a creation; emitted once the commit lands.
let mut created_substrates: Vec<&'static str> = Vec::new();
for (name, dt) in new_config.settings.datatables.iter_mut() {
let lookup = rename_src
.get(name.as_str())
@@ -3520,7 +3534,15 @@ async fn edit_datatable_config(
.unwrap_or(name.as_str());
dt.migrations_enabled = match old_datatables.get(lookup) {
Some(old) => old.migrations_enabled,
None => Some(true),
None => {
// Keyed by how the substrate is serialized into `workspace_settings`,
// so these line up with the `datatable_configured` adoption counts.
created_substrates.push(match dt.database.resource_type {
DataTableCatalogResourceType::Instance => "instance",
DataTableCatalogResourceType::Postgresql => "postgresql",
});
Some(true)
}
};
}
@@ -3578,6 +3600,10 @@ async fn edit_datatable_config(
tx.commit().await?;
for substrate in created_substrates {
windmill_common::feature_usage::log_feature_usage("datatable", "created", substrate);
}
crate::datatable_migrations::record_datatable_cascade_deployments(
&authed,
&db,
@@ -4661,6 +4687,66 @@ async fn edit_guest_access(
))
}
#[derive(Deserialize)]
struct EditGuestJwtKey {
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
/// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
/// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`.
public_key: Option<String>,
jwks_url: Option<String>,
}
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
/// Workspace-admin gated, like the guest switch: guests are free up to the instance
/// allowance on any plan, so configuring their key needs no licence. The key is
/// validated before it is stored so a typo is refused here, not silently on every guest
/// later: a PEM must parse as an RS/ES public key (HS* has no PEM form and is
/// unreachable), and a JWKS URL must be fetchable and hold at least one usable signing key.
async fn edit_guest_jwt_key(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(EditGuestJwtKey { public_key, jwks_url }): Json<EditGuestJwtKey>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let public_key = public_key.filter(|s| !s.trim().is_empty());
let jwks_url = jwks_url.filter(|s| !s.trim().is_empty());
if public_key.is_some() && jwks_url.is_some() {
return Err(Error::BadRequest(
"Set a PEM public key or a JWKS URL, not both".to_string(),
));
}
if let Some(pem) = public_key.as_deref() {
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
}
if let Some(url) = jwks_url.as_deref() {
windmill_common::guest_jwt::fetch_jwks(url).await?;
}
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
public_key,
jwks_url,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_guest_jwt_key",
ActionKind::Update,
&w_id,
None,
None,
)
.await?;
tx.commit().await?;
Ok(format!("Guest JWT key updated for workspace {w_id}"))
}
async fn edit_default_scripts(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -11190,6 +11276,7 @@ async fn load_workspace_authed(
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
job_id: base_authed.job_id,
credential_expiry: base_authed.credential_expiry,
});
};
@@ -11222,6 +11309,7 @@ async fn load_workspace_authed(
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
job_id: base_authed.job_id,
credential_expiry: base_authed.credential_expiry,
})
}
@@ -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, 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) 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, 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 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, 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) 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, 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 FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
+44 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.803.0
version: 1.804.0
title: Windmill API
contact:
@@ -3785,6 +3785,12 @@ paths:
guest_access_enabled:
type: boolean
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
guest_jwt_public_key:
type: string
description: PEM public key a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_jwks_url`.
guest_jwt_jwks_url:
type: string
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -5808,6 +5814,43 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_jwt_key:
post:
summary: set the key guest JWTs are verified against for this workspace
description: >-
A guest JWT (`jwt_guest_`) is minted by the embedding customer's own backend and
verified against this key: a PEM public key (RS/ES family, HS* refused) or a JWKS
URL, at most one. Both empty clears the workspace key; off cloud, verification then
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
The key is validated before it is stored.
operationId: editGuestJwtKey
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: The guest JWT verification key
required: true
content:
application/json:
schema:
type: object
properties:
public_key:
type: string
description: A PEM public key (RS or ES family).
jwks_url:
type: string
description: A JWKS URL whose keys are fetched and refreshed.
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/guest_usage:
get:
summary: the instance's standing against the guest allowance
+18 -14
View File
@@ -1520,20 +1520,24 @@ async fn guest_derived_token_constraints(
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
return Ok(None);
}
// The minter is known by prefix only; MIN over a (theoretical) prefix collision is
// the conservative side.
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
)
.bind(authed.token_prefix.as_deref().unwrap_or(""))
.bind(&authed.email)
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
.fetch_optional(db)
.await?;
let Some(parent_exp) = parent.flatten() else {
return Err(Error::NotAuthorized(
"guest session not found or has no expiry".to_string(),
));
// A guest JWT carries its own expiry and has no token row to look up; a signed-in
// guest session is a row found by prefix (MIN is the conservative side of a
// theoretical prefix collision). Either way the derived token caps on it, never on
// a fresh interval.
let parent_exp = if let Some(exp) = authed.credential_expiry {
exp
} else {
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
)
.bind(authed.token_prefix.as_deref().unwrap_or(""))
.bind(&authed.email)
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
.fetch_optional(db)
.await?;
parent.flatten().ok_or_else(|| {
Error::NotAuthorized("guest session not found or has no expiry".to_string())
})?
};
Ok(Some((
windmill_common::auth::GUEST_SESSION_LABEL.to_string(),
+1
View File
@@ -11750,6 +11750,7 @@ mod approval_view_gate_tests {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+1
View File
@@ -378,6 +378,7 @@ async fn inject_agent_authed(
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
},
job_id: None,
});
+1
View File
@@ -1421,6 +1421,7 @@ mod tests {
token_prefix: None,
read_only: false,
job_id,
credential_expiry: None,
}
}
+2
View File
@@ -109,6 +109,8 @@ pep440_rs.workspace = true
systemstat.workspace = true
size.workspace = true
rsa = { workspace = true, optional = true }
spki = { workspace = true }
pkcs1 = { workspace = true }
aes-gcm = { workspace = true, optional = true }
semver.workspace = true
File diff suppressed because it is too large Load Diff
+1
View File
@@ -67,6 +67,7 @@ pub mod flow_status;
pub mod flows;
pub mod folders;
pub mod global_settings;
pub mod guest_jwt;
pub mod indexer;
pub mod instance_config;
pub mod job_metrics;
+38
View File
@@ -6,6 +6,8 @@ pub const ALLOW_PRIVATE_MCP_SERVER_URLS_ENV: &str = "ALLOW_PRIVATE_MCP_SERVER_UR
pub const ALLOW_PRIVATE_SAML_METADATA_URLS_ENV: &str = "ALLOW_PRIVATE_SAML_METADATA_URLS";
pub const ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV: &str = "ALLOW_PRIVATE_GUEST_JWKS_URLS";
/// Why a URL failed SSRF validation.
///
/// The distinction matters for callers that gate private endpoints behind a
@@ -18,6 +20,9 @@ pub enum SsrfValidationError {
InvalidUrl(String),
/// Scheme is not `http`/`https`.
DisallowedScheme(String),
/// The URL uses `http` where `https` is required (guest JWKS). The private-host opt-in
/// also permits `http`, so, unlike the other scheme errors, this one the flag can fix.
HttpsRequired,
/// No host in the URL.
MissingHost,
/// DNS resolution failed for the host.
@@ -37,6 +42,9 @@ impl std::fmt::Display for SsrfValidationError {
f,
"URL scheme '{s}' is not allowed, only http and https are permitted"
),
SsrfValidationError::HttpsRequired => {
write!(f, "URL must use https")
}
SsrfValidationError::MissingHost => write!(f, "URL must have a host"),
SsrfValidationError::ResolutionFailed { host, source } => {
write!(f, "Failed to resolve host '{host}': {source}")
@@ -213,6 +221,36 @@ pub async fn validate_saml_metadata_url(url: &str) -> Result<ValidatedTarget, Ss
validate_url_for_ssrf(url).await
}
/// Validate a workspace admin's guest-JWKS URL and return the [`ValidatedTarget`] so
/// the fetch can pin the connect. `https` is required (the JWKS authenticates guest JWTs);
/// `ALLOW_PRIVATE_GUEST_JWKS_URLS` opts a private range AND plaintext `http` in, for dev.
pub async fn validate_guest_jwks_url(url: &str) -> Result<ValidatedTarget, SsrfValidationError> {
let parsed =
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
let allow_private = std::env::var(ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV)
.ok()
.is_some_and(|v| v == "true" || v == "1");
match parsed.scheme() {
"https" => {}
// Plaintext HTTP only under the explicit operator opt-in that also allows private
// hosts (dev/loopback): the JWKS supplies the keys that authenticate guest JWTs, so an
// on-path attacker who could replace an http response could forge accepted tokens.
"http" if allow_private => {}
"http" => return Err(SsrfValidationError::HttpsRequired),
scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())),
}
let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?;
if allow_private {
return Ok(ValidatedTarget::unpinned(host));
}
validate_url_for_ssrf(url).await
}
pub async fn validate_mcp_server_url(url: &str) -> Result<ValidatedTarget, SsrfValidationError> {
let parsed =
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
+23
View File
@@ -31,6 +31,29 @@ pub const USERNAME_GROUP_PREFIX: &str = "group-";
/// columns runnables and triggers store one in.
pub const PERMISSIONED_AS_MAX_LEN: usize = 55;
/// Whether any account exists for `email`: a `password` row (deactivated ones
/// included, since the sign-in path filters `disabled = false` and a re-enabled
/// account must not read as absent) or a `usr` row in any workspace (what a service
/// account has instead of a password). A guest is someone with none: the single rule
/// that keeps an account holder from ever holding a cheaper guest identity.
///
/// The address is lowercased before the lookup: accounts are stored lowercased, so a
/// mixed-case address would otherwise miss an existing account and be let through. The
/// comparison stays a plain equality (not `lower(email)`), so it uses the email index.
pub async fn has_any_account<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
executor: E,
email: &str,
) -> crate::error::Result<bool> {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
)
.bind(email.to_lowercase())
.fetch_one(executor)
.await
.map_err(|e| crate::error::Error::internal_err(format!("checking account for {email}: {e:#}")))
}
/// An email-shaped username is its own principal, which is how a superadmin acting without a
/// `usr` row is named (`usr.username` is constrained to `[\w-]+`, so a member never is). It is
/// decided before the group convention — an address is never a group's username — and one
+1 -1
View File
@@ -12,7 +12,7 @@ path = "src/lib.rs"
default = []
private = []
enterprise = ["windmill-common/enterprise"]
cloud = []
cloud = ["windmill-common/cloud"]
benchmark = ["windmill-common/benchmark"]
failpoints = []
prometheus = ["dep:prometheus"]
+38 -9
View File
@@ -2065,10 +2065,35 @@ fn apply_completed_job_cloud_usage(
queued_job: &MiniCompletedJob,
_duration: i64,
) {
if *CLOUD_HOSTED && !queued_job.is_flow() && _duration > 1000 {
if !queued_job.is_flow() {
meter_execution_seconds(
db,
&queued_job.workspace_id,
&queued_job.permissioned_as_email,
_duration,
);
}
}
/// Charge `_duration` of execution time to the cloud usage meters: the workspace's
/// monthly row, plus the per-user row on non-premium plans.
///
/// The unit is one finished **segment**, not one job. A Workflow-as-Code parent parks on
/// a sleep, an approval or its children and resumes with a fresh timer, so its compute
/// arrives here as several calls; metering only the one at completion would drop
/// everything it ran before its first park.
///
/// Fire-and-forget, like every other write to `usage`: billing must never hold up the
/// job that produced it.
///
/// `w_id` and `email` are billed as given and authorize nothing on their own — take them
/// from a job the caller already holds, never from request input.
#[cfg(feature = "cloud")]
pub fn meter_execution_seconds(db: &Pool<Postgres>, w_id: &str, email: &str, _duration: i64) {
if *CLOUD_HOSTED && _duration > 1000 {
let db = db.clone();
let w_id = queued_job.workspace_id.clone();
let email = queued_job.permissioned_as_email.clone();
let w_id = w_id.to_string();
let email = email.to_string();
let w_id2 = w_id.clone();
let email2 = email.clone();
tokio::task::spawn(async move {
@@ -7283,15 +7308,19 @@ async fn check_workspace_queue_cap<'c>(
// Ok(())
// }
pub fn canceled_job_to_result(job: &MiniPulledJob) -> serde_json::Value {
let reason = job
.canceled_reason
.as_deref()
.unwrap_or_else(|| "no reason given");
let canceler = job.canceled_by.as_deref().unwrap_or_else(|| "unknown");
/// The result payload a job cancelled anywhere carries. Callers that hold the cancel
/// outside a `MiniPulledJob` — a row read after the pull, say — go through this rather
/// than rebuilding the shape.
pub fn canceled_result(reason: Option<&str>, canceler: Option<&str>) -> serde_json::Value {
let reason = reason.unwrap_or("no reason given");
let canceler = canceler.unwrap_or("unknown");
serde_json::json!({"message": format!("Job canceled: {reason} by {canceler}"), "name": "Canceled", "reason": reason, "canceler": canceler})
}
pub fn canceled_job_to_result(job: &MiniPulledJob) -> serde_json::Value {
canceled_result(job.canceled_reason.as_deref(), job.canceled_by.as_deref())
}
/// Helper function to create a restarted module for branch/iteration restart
fn create_restarted_module(
module: &FlowStatusModule,
+1 -1
View File
@@ -23,7 +23,7 @@ benchmark = ["windmill-queue/benchmark", "windmill-common/benchmark"]
parquet = ["windmill-common/parquet", "windmill-object-store/parquet"]
flow_testing = []
failpoints = []
cloud = []
cloud = ["windmill-queue/cloud", "windmill-common/cloud"]
sqlx = []
deno_core = ["dep:windmill-runtime-nativets"]
libffi_mac = ["dep:libffi-sys"]
+5 -1
View File
@@ -69,7 +69,11 @@ class WindmillFinder(MetaPathFinder):
r = response.read().decode("utf-8")
if r == "WINDMILL_IS_FOLDER":
return ModuleSpec(name, WindmillLoader(name))
with open(fullpath, "w+") as f:
# Python parses .py as UTF-8 regardless of locale, so the
# file has to be written as UTF-8. Without this the ANSI
# code page on a Windows worker re-encodes every
# non-ASCII literal and the import dies on a SyntaxError.
with open(fullpath, "w+", encoding="utf-8") as f:
f.write(r)
return spec_from_file_location(name, fullpath)
except urllib.error.HTTPError as e:
+69 -36
View File
@@ -2572,7 +2572,8 @@ try {{
// WAC v2 post-execution: parse output and handle dispatch/suspend
if is_wac_v2 {
return handle_wac_v2_output(result, job, conn, modules, new_args.as_ref()).await;
return handle_wac_v2_output(result, job, conn, canceled_by, modules, new_args.as_ref())
.await;
}
Ok(result)
@@ -2602,11 +2603,13 @@ pub async fn handle_wac_v2_output(
result: Box<RawValue>,
job: &MiniPulledJob,
conn: &Connection,
canceled_by: &mut Option<CanceledBy>,
modules: &Option<std::collections::HashMap<String, windmill_common::scripts::ScriptModule>>,
preprocessed_args: Option<&HashMap<String, Box<RawValue>>>,
) -> error::Result<Box<RawValue>> {
use crate::wac_executor::{
load_checkpoint, parse_wac_output, update_checkpoint_for_dispatch, WacOutput,
load_checkpoint, parse_wac_output, update_checkpoint_for_dispatch,
wac_cancelled_mid_segment, WacOutput, WacPark,
};
use serde_json::Value;
use windmill_common::get_latest_flow_version_info_for_path;
@@ -2819,6 +2822,7 @@ pub async fn handle_wac_v2_output(
// Step 1: Save checkpoint, suspend parent, and seed child checkpoints
// in a single transaction — all BEFORE children become visible.
let segment_ms;
{
let mut tx = db.begin().await?;
@@ -2871,24 +2875,24 @@ pub async fn handle_wac_v2_output(
})?;
}
// Suspend parent before children become visible.
// Keep running = true so the normal pull query ignores it.
// The suspended pull query picks it up when suspend reaches 0
// (it checks: suspend_until IS NOT NULL AND suspend <= 0).
let suspend_count = num_steps as i32;
sqlx::query!(
"UPDATE v2_job_queue SET suspend = $2, suspend_until = now() + interval '14 day' WHERE id = $1",
job.id,
suspend_count,
// Suspend parent before children become visible, so a child that
// completes immediately finds a parked parent to decrement.
match crate::wac_executor::suspend_wac_parent(
&mut tx,
&job.id,
&job.workspace_id,
num_steps as i32,
14.0 * 24.0 * 3600.0,
)
.execute(&mut *tx)
.await
.map_err(|e| {
error::Error::internal_err(format!(
"Failed to suspend WAC parent job {}: {e}",
job.id
))
})?;
.await?
{
WacPark::Parked(ms) => segment_ms = ms,
// Returning here drops `tx`, unwriting the checkpoint and the timeline
// entries, so no child is ever pushed against a parent that never parked.
WacPark::Cancelled(cancel) => {
return Err(wac_cancelled_mid_segment(cancel, canceled_by))
}
}
tx.commit().await?;
}
@@ -3167,10 +3171,17 @@ pub async fn handle_wac_v2_output(
.execute(db)
.await;
// Unsuspend parent so the error propagates instead of a 14-day hang
// Unsuspend parent so the error propagates instead of a 14-day hang.
// Unlike the other suspend exits this one completes the job for real, so
// it needs its segment start back — the in-memory copy is what the pull
// stamped, before the suspend cleared the column.
let _ = sqlx::query!(
"UPDATE v2_job_queue SET suspend = 0, suspend_until = NULL WHERE id = $1",
"UPDATE v2_job_queue
SET suspend = 0, suspend_until = NULL,
started_at = coalesce(started_at, $2, now())
WHERE id = $1",
job.id,
job.started_at,
)
.execute(db)
.await;
@@ -3183,6 +3194,7 @@ pub async fn handle_wac_v2_output(
"WAC v2 parent job suspended"
);
crate::wac_executor::end_wac_segment(conn, job, segment_ms);
Err(error::Error::WacSuspended(format!(
"WAC v2 job {} suspended waiting for {} child job(s)",
job.id, num_steps
@@ -3361,15 +3373,23 @@ pub async fn handle_wac_v2_output(
}
// Suspend parent with suspend=1 (waiting for 1 approval event)
sqlx::query!(
"UPDATE v2_job_queue SET suspend = 1, suspend_until = now() + make_interval(secs => $2) WHERE id = $1",
job.id,
let segment_ms = match crate::wac_executor::suspend_wac_parent(
&mut tx,
&job.id,
&job.workspace_id,
1,
timeout_secs,
)
.execute(&mut *tx)
.await?;
.await?
{
WacPark::Parked(ms) => ms,
WacPark::Cancelled(cancel) => {
return Err(wac_cancelled_mid_segment(cancel, canceled_by))
}
};
tx.commit().await?;
crate::wac_executor::end_wac_segment(conn, job, segment_ms);
tracing::info!(
job_id = %job.id,
@@ -3453,18 +3473,25 @@ pub async fn handle_wac_v2_output(
})?;
}
// Suspend parent — it will auto-resume when suspend_until passes.
// Use suspend=1 (not 0) so the suspended pull query only picks it up
// when `suspend_until <= now()`, not via `suspend <= 0`.
sqlx::query!(
"UPDATE v2_job_queue SET suspend = 1, suspend_until = now() + make_interval(secs => $2) WHERE id = $1",
job.id,
let segment_ms = match crate::wac_executor::suspend_wac_parent(
&mut tx,
&job.id,
&job.workspace_id,
1,
sleep_secs,
)
.execute(&mut *tx)
.await?;
.await?
{
WacPark::Parked(ms) => ms,
WacPark::Cancelled(cancel) => {
return Err(wac_cancelled_mid_segment(cancel, canceled_by))
}
};
tx.commit().await?;
crate::wac_executor::end_wac_segment(conn, job, segment_ms);
tracing::info!(
job_id = %job.id,
@@ -3514,19 +3541,25 @@ pub async fn handle_wac_v2_output(
// Reset running=false so the job is immediately eligible for pickup.
// Unlike dispatch (which sets suspend>0), inline checkpoints don't suspend —
// the job should be re-run right away to continue past the cached step.
sqlx::query!(
"UPDATE v2_job_queue SET running = false, started_at = null WHERE id = $1",
// `prev` holds the pre-update row: RETURNING would see the cleared column.
let segment_ms = sqlx::query_scalar!(
"WITH prev AS (SELECT started_at FROM v2_job_queue WHERE id = $1)
UPDATE v2_job_queue q SET running = false, started_at = null
FROM prev WHERE q.id = $1
RETURNING (extract(epoch FROM now() - prev.started_at) * 1000)::bigint",
job.id,
)
.execute(&mut *tx)
.fetch_optional(&mut *tx)
.await
.map_err(|e| {
error::Error::internal_err(format!(
"Failed to reset running state for inline checkpoint: {e}"
))
})?;
})?
.flatten();
tx.commit().await?;
crate::wac_executor::end_wac_segment(conn, job, segment_ms);
Err(error::Error::WacSuspended(format!(
"WAC v2 job {} inline checkpoint for step {}",
+22 -10
View File
@@ -117,6 +117,12 @@ const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download
const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto");
pub const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py");
/// Every file exchanged with a job is UTF-8 by construction, so the interpreter
/// must agree. A job env carries no locale: Linux then picks UTF-8 on its own
/// (PEP 540), Windows picks the ANSI code page. Applied after the whitelisted
/// envs so the protocol is not the user's to opt out of.
pub const PYTHON_UTF8_ENVS: [(&str, &str); 1] = [("PYTHONUTF8", "1")];
/// Render loader.py with the TEMP_SCRIPT_REFS placeholder substituted by a
/// Python dict literal. Preview jobs pass a path -> temp-hash map so relative
/// imports resolve from not-yet-deployed local content; deployed runs pass
@@ -818,7 +824,7 @@ pub async fn handle_python_job(
del pre_args[k]
kwargs = inner_script.preprocessor(**pre_args)
kwrags_json = res_to_json(kwargs, type(kwargs))
with open("args.json", 'w') as f:
with open("args.json", 'w', encoding="utf-8") as f:
f.write(kwrags_json)"#
)
} else {
@@ -853,7 +859,7 @@ pub async fn handle_python_job(
_pre_result = asyncio.run(_pre_result)
kwargs = _pre_result if _pre_result is not None else {{}}
_pre_json = json.dumps(kwargs, separators=(',', ':'), default=str)
with open("args.json", 'w') as f:
with open("args.json", 'w', encoding="utf-8") as f:
f.write(_pre_json)
sys.stdout.write("wm_res[preprocessed_args]:" + _pre_json + "\n")
sys.stdout.flush()"#
@@ -874,11 +880,11 @@ import sys
from {module_dir_dot} import {last} as inner_script
from wmill.client import _run_workflow
with open("args.json") as f:
with open("args.json", encoding="utf-8") as f:
kwargs = json.load(f, strict=False)
{transforms}
with open("checkpoint.json") as f:
with open("checkpoint.json", encoding="utf-8") as f:
checkpoint = json.load(f, strict=False)
result_json = os.path.join(os.path.abspath(os.path.dirname(__file__)), "result.json")
@@ -904,12 +910,12 @@ try:
print("")
print("--- WAC: complete ---")
output_json = json.dumps(output, separators=(',', ':'), default=str)
with open(result_json, 'w') as f:
with open(result_json, 'w', encoding="utf-8") as f:
f.write(output_json)
except BaseException as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
tb = traceback.format_tb(exc_traceback)
with open(result_json, 'w') as f:
with open(result_json, 'w', encoding="utf-8") as f:
err = {{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}
extra = e.__dict__
if extra and len(extra) > 0:
@@ -936,7 +942,7 @@ import sys
from {module_dir_dot} import {last} as inner_script
import re
with open("args.json") as f:
with open("args.json", encoding="utf-8") as f:
kwargs = json.load(f, strict=False)
args = {{}}
{transforms}
@@ -972,12 +978,12 @@ try:
print("WM_STREAM: " + chunk.replace('\n', '\\n'))
res = None
res_json = res_to_json(res, typ)
with open(result_json, 'w') as f:
with open(result_json, 'w', encoding="utf-8") as f:
f.write(res_json)
except BaseException as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
tb = traceback.format_tb(exc_traceback)
with open(result_json, 'w') as f:
with open(result_json, 'w', encoding="utf-8") as f:
err = {{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}
extra = e.__dict__
if extra and len(extra) > 0:
@@ -1117,6 +1123,7 @@ mount {{
)
.await?,
)
.envs(PYTHON_UTF8_ENVS)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
@@ -1152,6 +1159,7 @@ mount {{
)
.await?,
)
.envs(PYTHON_UTF8_ENVS)
.env("PATH", PATH_ENV.as_str())
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
@@ -1223,6 +1231,7 @@ mount {{
result,
job,
conn,
canceled_by,
modules,
new_args.as_ref(),
))
@@ -3430,7 +3439,10 @@ pub async fn start_worker(
)
.await;
let mut proc_envs = HashMap::new();
let mut proc_envs: HashMap<String, String> = PYTHON_UTF8_ENVS
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let additional_python_paths_folders = additional_python_paths.iter().join(":");
proc_envs.insert("PYTHONPATH".to_string(), additional_python_paths_folders);
proc_envs.insert("PATH".to_string(), PATH_ENV.to_string());
+118
View File
@@ -1,11 +1,13 @@
use serde::Deserialize;
use serde_json::value::RawValue;
use serde_json::Value;
use sqlx::{Postgres, Transaction};
use uuid::Uuid;
use windmill_common::error::{self, Error};
use windmill_common::scripts::ScriptLang;
use windmill_common::DB;
use windmill_queue::CanceledBy;
// Checkpoint model + persistence primitives live in windmill-common so the
// API server can use them without pulling in the full worker crate. Re-export
@@ -85,6 +87,122 @@ fn default_dispatch_type() -> String {
"inline".to_string()
}
/// What `suspend_wac_parent` did with the parent's queue row.
#[derive(Debug)]
pub enum WacPark {
/// Parked. Carries the segment that just ended, in milliseconds, for `end_wac_segment`.
Parked(Option<i64>),
/// A cancel reached the row while this segment was running, so the park was skipped.
/// Carries who cancelled, for the completion that must happen instead.
Cancelled(CanceledBy),
}
/// Park a WAC v2 parent in the queue until `suspend` reaches 0 or `suspend_secs`
/// elapses, whichever comes first. `running` stays true so the normal pull query
/// skips the row; only the suspended pull query takes it back. The `id`/`workspace_id`
/// pair is a consistency check, not an authorization one — callers must already hold
/// the job (every one of them passes a job its own worker pulled).
///
/// `started_at` is cleared because the parent holds no worker while parked. The pull
/// re-stamps it (`started_at = coalesce(started_at, now())`), and every path that
/// completes a job without a worker-measured duration — a cancel, the child-failure
/// handler — falls back to `now() - started_at`. Left pointing at the first segment,
/// that fallback reports the whole sleep or approval wait as execution time.
pub async fn suspend_wac_parent(
tx: &mut Transaction<'_, Postgres>,
job_id: &Uuid,
w_id: &str,
suspend: i32,
suspend_secs: f64,
) -> error::Result<WacPark> {
// `FOR UPDATE` orders this against a concurrent soft cancel, which writes `suspend = 0`
// and leaves acting on `canceled_by` to the next pull. Parking on top of that keeps the
// row unpullable until `suspend_until` — up to the full `sleep()` — so a cancel already
// on the row has to stand the park down rather than be overwritten by it.
let prev = sqlx::query!(
"SELECT canceled_by, canceled_reason,
(extract(epoch FROM now() - started_at) * 1000)::bigint AS segment_ms
FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE",
job_id,
w_id,
)
.fetch_optional(&mut **tx)
.await
.map_err(|e| Error::internal_err(format!("Failed to read WAC parent job {job_id}: {e}")))?
// Silently parking nothing is unrecoverable on the dispatch arm: the children are
// pushed right after and decrement a `suspend` that was never set, so the parent
// sits out its whole suspend window instead of resuming.
.ok_or_else(|| {
Error::internal_err(format!(
"WAC parent job {job_id} not in the queue of workspace {w_id} to suspend"
))
})?;
if let Some(username) = prev.canceled_by {
return Ok(WacPark::Cancelled(CanceledBy {
username: Some(username),
reason: prev.canceled_reason,
}));
}
sqlx::query!(
"UPDATE v2_job_queue
SET suspend = $3, suspend_until = now() + make_interval(secs => $4), started_at = null
WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
suspend,
suspend_secs,
)
.execute(&mut **tx)
.await
.map_err(|e| Error::internal_err(format!("Failed to suspend WAC parent job {job_id}: {e}")))?;
Ok(WacPark::Parked(prev.segment_ms))
}
/// Turn a cancel that landed mid-segment into the error the executor returns, so the job
/// completes on this pass instead of parking. Setting the worker's `canceled_by` is what
/// makes it land as `canceled` rather than `failure`: the row was cancelled after this
/// worker pulled the job, so the in-memory copy still reads as uncancelled.
///
/// The completion charges the segment that just ended, so callers must not also hand it to
/// `end_wac_segment`.
pub(crate) fn wac_cancelled_mid_segment(
cancel: CanceledBy,
canceled_by: &mut Option<CanceledBy>,
) -> Error {
let payload = windmill_common::worker::to_raw_value(&windmill_queue::canceled_result(
cancel.reason.as_deref(),
cancel.username.as_deref(),
));
*canceled_by = Some(cancel);
Error::ExecutionRawError(payload)
}
/// Charge the execution segment a WAC parent just finished. Segments are metered as they
/// end rather than summed at completion, so a workflow that sleeps for days is billed for
/// the compute it used, when it used it — and the final segment is charged by the ordinary
/// completion path.
///
/// Call this only where the parent really parks. On a rollback that goes on to complete
/// the job, the completion charges the same segment and it would be billed twice.
pub(crate) fn end_wac_segment(
_conn: &windmill_common::worker::Connection,
_job: &windmill_queue::MiniPulledJob,
_segment_ms: Option<i64>,
) {
#[cfg(feature = "cloud")]
if let (windmill_common::worker::Connection::Sql(db), Some(segment_ms)) = (_conn, _segment_ms) {
windmill_queue::meter_execution_seconds(
db,
&_job.workspace_id,
&_job.permissioned_as_email,
segment_ms,
);
}
}
/// Parse the WAC result from result.json content.
pub fn parse_wac_output(result: &RawValue) -> error::Result<WacOutput> {
serde_json::from_str(result.get())
+1 -1
View File
@@ -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.803.0";
export const VERSION = "v1.804.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -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.803.0";
export const VERSION = "1.804.0";
+62
View File
@@ -63,3 +63,65 @@ gap, rebuild and republish the `latest` / patch tags:
Scan the published images (e.g. Trivy / Defender) after rebuilds to confirm the
base-OS finding count stays low.
# Verifying image signatures, SBOMs and provenance
Release images are signed and attested at publish time:
- **cosign keyless signature** on the pushed manifest digest (index and
per-arch manifests), via GitHub OIDC — no long-lived signing key exists
(`.github/actions/sign-attest-image`).
- **SBOMs** are generated at build time (`sbom: true` on the depot build
step) and embedded in the image index as BuildKit attestation manifests —
one SPDX document per platform. They are part of the signed index digest,
so the cosign signature covers them. They are not sent to a transparency
log: SPDX documents for these images run tens of MB, beyond what Rekor or
GitHub attestations accept as payloads.
- **SLSA build provenance** recorded as a GitHub artifact attestation and
pushed to the registry (`actions/attest-build-provenance`).
## What is covered
Only images published from a release tag (`v*`) are signed: `windmill`,
`windmill-ee`, `windmill-ee-cuda`, `windmill-slim`, `windmill-ee-slim`,
`windmill-full`, `windmill-ee-full` (`.github/workflows/docker-image.yml`),
`windmill-cli` (`build_cli_image.yml`) and `windmill-extra`
(`publish_extra.yml`). The `:latest` and `:main` tags are repointed on
every `main` push as well as on releases, so they resolve to a signed
digest only until the next `main` build lands — verify a version tag or a
digest, not `:latest`. Development images (`:dev`, branch builds,
`windmill-test`), the dispatch-only RHEL/rpi images and the `caddy-l4`
image are not signed.
## How to verify
Signatures are keyless: trust is anchored in the Fulcio certificate identity,
which for these images is the *calling workflow file at a `v*` tag ref* in
this repository. Verify a signature with cosign (v2.x):
```bash
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/windmill-labs/windmill/\.github/workflows/(docker-image|publish_extra|build_cli_image)\.yml@refs/tags/v' \
ghcr.io/windmill-labs/windmill:<version>
```
Extract the embedded SBOM (per platform; verify the signature first — it
covers the index these documents live in):
```bash
docker buildx imagetools inspect ghcr.io/windmill-labs/windmill:<version> \
--format '{{ json .SBOM }}'
```
Verify SLSA provenance through GitHub's attestation API:
```bash
gh attestation verify oci://ghcr.io/windmill-labs/windmill:<version> \
-R windmill-labs/windmill
```
Note for registry housekeeping: cosign stores signatures as extra
`sha256-<digest>.sig` tags in the same ghcr package, and the pushed
provenance attestations live there as referrer artifacts — any
tag-retention automation must not prune them.
+4 -3
View File
@@ -4,9 +4,10 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 32 registered actions across fifteen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `flow_editor`, `flow_run`, `flow_step`, `run_form`,
`debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`, `sso_groups_claim`). Nearly all of the
It currently carries 42 registered actions across seventeen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
`flow_step`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`,
`sso_groups_claim`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.803.0",
"version": "1.804.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.803.0",
"version": "1.804.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.803.0",
"version": "1.804.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+7 -16
View File
@@ -209,33 +209,24 @@
U+1fac6, U+1fae0-1fae6, U+1fae8-1faea, U+1faef-1faf8;
}
.prose-xs ul {
margin-top: 0.5rem;
list-style-type: '- ';
padding-left: 1.5rem;
}
/* Bullets read as a dash rather than a disc. Only the glyph is overridden:
indentation and vertical rhythm stay with Tailwind Typography so ordered
and unordered lists line up with each other. */
.prose ul {
margin-top: 1.5rem;
list-style-type: '- ';
padding-left: 3rem;
}
/* The '- ' list markers, horizontal rules and blockquote bars otherwise
fall through to Tailwind Typography's default bullet/border colors, which
are nearly invisible on dark backgrounds (e.g. the AI chat). Use
theme-aware tokens so they stay readable in both light and dark mode. */
.prose-xs ul > li::marker,
.prose ul > li::marker {
/* List markers, horizontal rules and blockquote bars take the tertiary/light
tokens the typography config maps them to, which is too faint to read on
the denser markdown surfaces (e.g. the AI chat). Step them up one. */
.prose :is(ul, ol) > li::marker {
color: rgb(var(--color-text-secondary));
}
.prose-xs hr,
.prose hr {
border-top-color: rgb(var(--color-border-normal));
}
.prose-xs blockquote,
.prose blockquote {
border-left-color: rgb(var(--color-border-normal));
}
@@ -6,30 +6,34 @@
import { createEventDispatcher } from 'svelte'
interface Props {
email: string;
username: string;
isConflict?: boolean;
noPadding?: boolean;
email: string
username: string
isConflict?: boolean
noPadding?: boolean
}
let {
email,
username = $bindable(),
isConflict = false,
noPadding = false
}: Props = $props();
let { email, username = $bindable(), isConflict = false, noPadding = false }: Props = $props()
let loading = $state(false)
let usernameInfo:
| {
username: string
workspace_usernames: {
workspace_id: string
username: string
}[]
}
| undefined = $state(undefined)
type UsernameInfo = {
username: string
workspace_usernames: {
workspace_id: string
username: string
}[]
}
let usernameInfo: UsernameInfo | undefined = $state(undefined)
let affectedWorkspaces = $derived.by(
() => usernameInfo?.workspace_usernames.filter((w) => w.username !== username) ?? []
)
let isRenaming = $derived.by(
() =>
usernameInfo !== undefined &&
(username !== usernameInfo.username || affectedWorkspaces.length > 0)
)
function handleKeyUp(event: KeyboardEvent) {
const key = event.key
@@ -53,6 +57,11 @@
const dispatch = createEventDispatcher()
async function renameUser() {
// Renaming before the current usernames are known would apply a change whose scope
// the "Manual action required" warning could not have been shown for.
if (!usernameInfo) {
return
}
loading = true
try {
const automateUsernameCreation =
@@ -102,31 +111,30 @@
Users are required to have an instance-wide username that is shared across all workspaces.
However, this user has different usernames in different workspaces.
{#if usernameInfo?.workspace_usernames && usernameInfo.workspace_usernames.filter((w) => w.username !== username).length > 0}
{#if affectedWorkspaces.length > 0}
<br />
<br />
Workspaces requiring username modification: {usernameInfo.workspace_usernames
.filter((w) => w.username !== username)
Workspaces requiring username modification: {affectedWorkspaces
.map((wu) => `${wu.workspace_id} (${wu.username})`)
.join(', ')}
{/if}
</Alert>
{/if}
{#if !isConflict && usernameInfo?.workspace_usernames && usernameInfo.workspace_usernames.filter((w) => w.username !== username).length > 0}
{#if !isConflict && affectedWorkspaces.length > 0}
<Alert title="Concerned workspaces" class="mb-4">
{usernameInfo.workspace_usernames
.filter((w) => w.username !== username)
.map((wu) => `${wu.workspace_id}`)
.join(', ')}
{affectedWorkspaces.map((wu) => `${wu.workspace_id}`).join(', ')}
</Alert>
{/if}
<Alert type="warning" title="Manual action required" class="mb-4">
This operation does not handle references in scripts, workflows and applications to scripts in
the workspace, and references in resources to variables. You will have to handle those manually.
<br />
</Alert>
{#if isRenaming}
<Alert type="warning" title="Manual action required" class="mb-4">
This operation does not handle references in scripts, workflows and applications to scripts in
the workspace, and references in resources to variables. You will have to handle those
manually.
<br />
</Alert>
{/if}
<Button
variant="default"
@@ -136,7 +144,7 @@
dispatch('close')
})
}}
disabled={email === undefined || !username}
disabled={email === undefined || !username || !usernameInfo}
{loading}
>
Confirm username change
@@ -4,6 +4,7 @@
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
import { CornerDownLeft } from 'lucide-svelte'
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
@@ -97,9 +98,11 @@
for (;;) {
const choice = await promptDdl(statement)
if (choice === 'cancel') {
logDdlGuardChoice('cancelled')
return { proceed: false, code, ranMigration: migrationRan }
}
if (choice === 'run') {
logDdlGuardChoice('run_anyway')
kept.push(statement)
break
}
@@ -107,6 +110,7 @@
// created; if the modal was cancelled, loop back to the prompt.
const created = await openMigrationModal(statement)
if (created) {
logDdlGuardChoice('migrated')
break
}
}
@@ -32,6 +32,7 @@
import Alert from './common/alert/Alert.svelte'
import AutoDataTable from './table/AutoDataTable.svelte'
import Markdown from 'svelte-exmarkdown'
import { markdownProse } from './markdownProse'
import Toggle from './Toggle.svelte'
import FileDownload from './common/fileDownload/FileDownload.svelte'
@@ -1229,7 +1230,7 @@
</div>
</div>
{:else if !forceJson && resultKind === 'markdown'}
<div class="prose-xs dark:prose-invert !list-disc !list-outside">
<div class={markdownProse.sm}>
<Markdown md={result?.md ?? result?.markdown} />
</div>
{:else if largeObject || hasBigInt}
@@ -6,12 +6,11 @@
interface Props {
md: string
noPadding?: boolean
/** Shared prose stack to render with. Omitted keeps the legacy `prose-xs`,
* which the flow-graph notes are laid out against. */
/** Shared prose stack to render with. */
prose?: MarkdownProseSize
}
let { md, noPadding, prose }: Props = $props()
let { md, noPadding, prose = 'sm' }: Props = $props()
// Rendering markdown turns `![](url)` into a real `<img>`, i.e. a request. On the
// public replay page the source is a recording from an arbitrary origin and the
@@ -21,7 +20,7 @@
let asPlainText = $derived(isOfflineReplay())
</script>
<div class="{prose ? markdownProse[prose] : '!prose-xs'} {noPadding ? '' : 'pgap'}">
<div class="{markdownProse[prose]} {noPadding ? '' : 'pgap'}">
{#if asPlainText}
<p class="whitespace-pre-wrap">{md}</p>
{:else}
@@ -6,7 +6,6 @@
import { createEventDispatcher } from 'svelte'
import Button from './common/button/Button.svelte'
import Popover from './meltComponents/Popover.svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
import ChangeInstanceEmailInner from './ChangeInstanceEmailInner.svelte'
import { UserService } from '$lib/gen'
@@ -53,11 +52,8 @@
</script>
<Popover
floatingConfig={{
strategy: 'fixed',
placement: 'left-end',
middleware: [offset(8), flip(), shift()]
}}
floatingConfig={{ strategy: 'fixed', placement: 'left-end' }}
contentClasses="pt-9"
closeButton
>
{#snippet trigger()}
@@ -66,7 +62,10 @@
>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-8 max-w-sm p-4">
<!-- The scroll sits here rather than on the popover box, which is what the close button
is positioned against — box-level overflow scrolls that button out of reach. The
box's `pt-9` clears the button's 34px, so the scrollbar starts below it. -->
<div class="flex flex-col gap-8 max-w-sm p-4 pt-0 max-h-[70vh] overflow-y-auto">
<ChangeInstanceEmailInner
{email}
{username}
@@ -1076,12 +1076,15 @@
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, and the plan tier and quota shown when the execution meter is opened, last
30 days)</li
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
deployed items use)</li
>feature adoption (counts of which flow, script, trigger, worker and data table
features your deployed items use, including how many apps run sandboxed, how many data
tables exist per database kind, how many use migrations, and what references them)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
@@ -1135,12 +1138,15 @@
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, and the plan tier and quota shown when the execution meter is opened, last
30 days)</li
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
deployed items use)</li
>feature adoption (counts of which flow, script, trigger, worker and data table
features your deployed items use, including how many apps run sandboxed, how many data
tables exist per database kind, how many use migrations, and what references them)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
+9 -3
View File
@@ -410,7 +410,7 @@
if (!redirectSaml()) autoRedirecting = false
} else if (logins?.some((l) => l.type === autoLogin)) {
autoRedirecting = true
if (!storeRedirect(autoLogin)) {
if (!storeRedirect(autoLogin, true)) {
autoRedirecting = false
sendUserToast('Popup blocked — please click the sign-in button to continue.', true)
}
@@ -546,13 +546,17 @@
}
}
function storeRedirect(provider: string): boolean {
// `automatic` marks the auto-login redirect, the one login that has to reach the
// provider without drawing anything. It suppresses the provider's extra params —
// Google's and Microsoft's account chooser — which every other login gets.
function storeRedirect(provider: string, automatic: boolean): boolean {
// The kitchen sink renders real provider buttons; clicking one must not leave the page.
if (previewConfig) return true
markLoginMethodPending({ kind: 'oauth', provider })
persistRd()
const params = new URLSearchParams()
if (popup) params.set('close', 'true')
if (automatic) params.set('auto', 'true')
if (guestApp) params.set('guest_app', guestApp)
const query = params.size > 0 ? '?' + params.toString() : ''
let url = base + '/api/oauth/login/' + provider + query
@@ -716,7 +720,9 @@
unifiedSize="lg"
startIcon={entry.icon ? { icon: entry.icon, classes: 'h-4' } : undefined}
onClick={() =>
entry.method.kind === 'saml' ? redirectSaml() : storeRedirect(entry.method.provider)}
entry.method.kind === 'saml'
? redirectSaml()
: storeRedirect(entry.method.provider, false)}
>
Continue with {entry.displayName}
</Button>
@@ -25,6 +25,7 @@
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
import { logFeatureUsage } from '$lib/utils/featureUsage'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -176,6 +177,10 @@
}${customPath}`
)
// The app URL a guest JWT rides on: append `guest.<jwt>` and the viewer authenticates the
// token as a seatless guest. Uses the custom URL when set, else the public secret URL.
let guestJwtBase = $derived(customPath !== undefined ? fullCustomUrl : secretUrlHref)
// When embedding a raw app in an iframe inside another Windmill app (or any
// cross-origin-isolated page), the embedded document must set COEP. The
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
@@ -348,6 +353,12 @@
checked={policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
// Counted where the toggle is flipped rather than where the policy is
// persisted: a not-yet-deployed app only mutates it locally, and skipping
// those would read as unused in the case where it is picked up front.
logFeatureUsage('app_sandbox', 'toggled', {
key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}`
})
// Frontend API access exists only for a sandboxed app, so turning
// isolation off drops the declared scopes with it rather than leaving
// them set but inert.
@@ -500,8 +511,8 @@
Anyone your identity provider authenticates can open this app without a Windmill account.
They join no workspace. Members of this workspace can open it too.
{#if guestUsage}
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance
in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
? 'every four guests count as one seat'
: 'new guests are refused until the count drops'}.
{/if}
@@ -543,6 +554,38 @@
{/if}
</div>
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
<div class="text-xs font-semibold text-emphasis">
Embed for your own authenticated users (guest JWT)
</div>
<div class="text-xs text-secondary">
To open this app for a user your own product already authenticates, mint a short-lived JWT
in your backend and append it to the app URL as <code>guest.&lt;jwt&gt;</code>. Each token
is its own seatless guest, confined to this app — no shared secret and no Windmill
account, unlike the plain secret URL above.
</div>
<div class="text-xs text-secondary">
Windmill verifies the token against the workspace's guest JWT key (Workspace settings →
Guests) — a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's
configured issuer (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}. Set
the <b>public</b> half there; in your backend, sign each token with the matching
<b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is
refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>,
<code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead).
</div>
<ClipboardPanel
content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)}
size="md"
/>
<div class="text-2xs text-secondary">
Replace <code>YOUR_GUEST_JWT</code> with the token your backend signs per user. Past the instance's
free guest allowance a new guest email is refused (see the count above); guests already seen
in the window keep working.
</div>
</div>
{/if}
<div class="mt-4">
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="warning" title="Admin only" size="xs">
@@ -25,6 +25,7 @@
import { sendUserToast, type Item } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { getToolNameError } from '$lib/components/flows/agentToolUtils'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import autosize from '$lib/autosize'
interface Props {
@@ -104,7 +105,16 @@
if (flowModuleValue?.type !== 'script') return
const hash =
flowModuleValue.hash ?? (await getLatestHashForScript(flowModuleValue.path, opWs))
$scriptEditorDrawer?.openDrawer(hash, () => {
// Same reason the settings item below is gated: the local-dev editors publish
// the context store but never render the drawer, so an unmounted one makes
// this a no-op — and a no-op must not be counted as an editor open.
const drawer = $scriptEditorDrawer
if (!drawer) return
logFeatureUsage('flow_step', 'script_edit', { key: 'opened' })
// The drawer only runs this callback once a new version is deployed, so it is
// what separates opening the editor from actually editing the script here.
drawer.openDrawer(hash, () => {
logFeatureUsage('flow_step', 'script_edit', { key: 'saved' })
dispatch('reload')
sendUserToast('Script has been updated')
})
@@ -1,7 +1,7 @@
<script lang="ts">
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { Check, X } from 'lucide-svelte'
import { NOTE_COLORS, NoteColor } from './noteColors'
import { NOTE_COLORS, NOTE_TEXT_COLOR_OVERRIDE, NoteColor } from './noteColors'
import { stopPropagation, preventDefault } from 'svelte/legacy'
interface Props {
@@ -127,13 +127,13 @@
{:else if note}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {editMode
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {NOTE_TEXT_COLOR_OVERRIDE} {editMode
? 'cursor-pointer'
: ''}"
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
>
<GfmMarkdown md={note} noPadding />
<GfmMarkdown md={note} prose="xs" noPadding />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -105,6 +105,15 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
}
}
// A note renders its text as markdown, and the prose stack sets body, heading and
// strong colors directly on those elements — which would beat the note color the
// wrapper only passes down by inheritance, and leave the render mismatched against
// the textarea shown while editing. Pin every descendant back to the note color.
// Arbitrary value, not `text-inherit`: this config replaces the color palette outright and
// defines no `inherit` key, so the named utility would be silently generated as nothing.
// (Kept as a literal: Tailwind's scanner reads class names verbatim from this file.)
export const NOTE_TEXT_COLOR_OVERRIDE = '[&_*]:!text-[inherit]'
// Color swatch colors for the picker (solid colors for the palette dots)
export const NOTE_COLOR_SWATCHES: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'bg-yellow-400',
@@ -8,6 +8,7 @@
import {
NoteColor,
NOTE_COLORS,
NOTE_TEXT_COLOR_OVERRIDE,
DEFAULT_NOTE_COLOR,
MIN_NOTE_WIDTH,
MIN_NOTE_HEIGHT
@@ -294,7 +295,8 @@
<div
class={twMerge(
'w-full text-xs rounded-md break-words overflow-hidden',
colorConfig.text
colorConfig.text,
NOTE_TEXT_COLOR_OVERRIDE
)}
>
<GfmMarkdown md={textForDisplay} noPadding />
+2 -2
View File
@@ -22,13 +22,13 @@ const base =
// One vertical rhythm for sm/doc; heading margins stay per-preset (fixed, not
// the plugin's em-based ones) so 'doc' can breathe more between sections.
const rhythm = 'prose-sm leading-snug prose-ul:!pl-6'
const rhythm = 'prose-sm leading-snug'
const bodyXs =
'text-primary prose-p:text-primary prose-li:text-primary prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs prose-table:text-xs'
export const markdownProse = {
xs: `${base} prose-sm leading-snug prose-ul:!pl-5 prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`,
doc: `${base} ${rhythm} ${bodyXs} prose-headings:mt-8 prose-headings:mb-2 prose-headings:font-semibold prose-headings:text-emphasis prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs prose-pre:bg-transparent prose-pre:p-0`
} as const
@@ -127,6 +127,8 @@
{/if}
{:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)}
Waiting executor (<TimeAgo agoOnlyIfRecent date={job.scheduled_for || ''} />)
{:else if 'running' in job && job.running && job.suspend}
Suspended (created <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
{:else}
Waiting executor (<TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
{/if}
@@ -34,6 +34,7 @@
import DataTableConnectionReport from './DataTableConnectionReport.svelte'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import { probeDatatableConnection } from './datatableProbe'
import { logDatatableWizard } from './datatableTelemetry'
import {
anythingClaimed,
claimOf,
@@ -526,11 +527,13 @@
await loadTargetUser()
reset(parked ?? resume)
opened = true
logDatatableWizard({ step: 'opened' })
}
function selectProvider(key: Provider) {
if (key === wiz.provider) return
wiz.provider = key
logDatatableWizard({ step: 'picked', provider: key })
invalidate()
if (key === 'instance') wiz.instance.dbName ??= defaultInstanceDbName()
}
@@ -833,6 +836,11 @@
createdProjects
}
}
// The setup's own verdict, so a data table that exists counts as done even when the
// caller's appended `onFinishAlso` step failed after it.
if (wiz.provider) {
logDatatableWizard({ step: result?.ok ? 'done' : 'failed', provider: wiz.provider })
}
onDone()
}
}
@@ -0,0 +1,41 @@
import { logFeatureUsage } from '$lib/utils/featureUsage'
// Anonymous counters for the data table surfaces the backend cannot see: which substrate the
// add-wizard is pointed at and how far a run gets, and what the DDL guard talks people into.
// Same rules as every other `logFeatureUsage` caller: aggregated counts only, and the keys
// below are the whole vocabulary — no data table name, connection string, resource path or SQL
// ever reaches here.
/** The substrate a wizard run is pointed at. Mirrors the wizard's own `Provider`. */
export type DatatableWizardProvider = 'supabase' | 'instance' | 'resource'
export type DatatableWizardEvent =
/** The wizard was opened, including a run resumed from the Supabase redirect. */
| { step: 'opened' }
/** A substrate was picked. Re-picking a different one counts again, by design: the
* abandoned branch is the interesting half of a funnel. */
| { step: 'picked'; provider: DatatableWizardProvider }
/** A run finished, with the verdict the checklist reported. */
| { step: 'done' | 'failed'; provider: DatatableWizardProvider }
export function logDatatableWizard(event: DatatableWizardEvent): void {
const key = event.step === 'opened' ? 'opened' : `${event.step}_${event.provider}`
logFeatureUsage('datatable', 'wizard', { key })
}
export type DdlGuardChoice =
/** The DDL was run ad-hoc, against the guard's advice. */
| 'run_anyway'
/** The DDL became a migration definition. */
| 'migrated'
/** The statement was abandoned, so nothing ran. */
| 'cancelled'
/**
* Counted once per prompt that reaches a terminal choice. Picking "create a migration" and then
* dismissing the modal loops back to the prompt instead, and is deliberately not counted: it is
* the same statement still undecided, not a fourth outcome.
*/
export function logDdlGuardChoice(choice: DdlGuardChoice): void {
logFeatureUsage('datatable', 'ddl_guard', { key: choice })
}
@@ -1,3 +1,7 @@
<!-- `@(root)` skips the (logged) layout on purpose: that layout renders nothing but
"Loading user..." until `$userStore` is set, and `$userStore` is only ever filled in
for a chosen workspace. An MCP client sends the browser straight here after login, so
in gateway mode there is no workspace yet — picking one is what this page is for. -->
<script lang="ts">
import { page } from '$app/state'
import CenteredModal from '$lib/components/CenteredModal.svelte'
@@ -138,9 +142,13 @@
</script>
{#if !redirectUriValid}
<p class="text-center text-sm text-primary mb-6"> Error: invalid or unsafe redirect_uri </p>
<CenteredModal title="Authorization Request">
<p class="text-center text-sm text-primary"> Error: invalid or unsafe redirect_uri </p>
</CenteredModal>
{:else if !isGateway && !workspaceId}
<p class="text-center text-sm text-primary mb-6">Error: missing workspace_id</p>
<CenteredModal title="Authorization Request">
<p class="text-center text-sm text-primary">Error: missing workspace_id</p>
</CenteredModal>
{:else}
<CenteredModal title={success ? 'Authorization Approved' : 'Authorization Request'}>
{#if success}
@@ -0,0 +1,14 @@
import { readdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
// Renaming the page back drops it into the (logged) layout, where it hangs on
// "Loading user..." with no type error and no other failing test — see the page header.
const routeDir = dirname(fileURLToPath(import.meta.url))
describe('mcp oauth consent route', () => {
it('escapes the (logged) layout', () => {
expect(readdirSync(routeDir)).toContain('+page@(root).svelte')
})
})
@@ -191,6 +191,19 @@
let guestAccessEnabled: boolean = $state(false)
let guestUsage: GuestUsage | undefined = $state(undefined)
let initialGuestAccessEnabled: boolean = $state(false)
// A guest JWT is verified against one key: a PEM public key, or a JWKS URL. The
// type picks which field is live; the other is cleared on save.
let guestJwtKeyType = $state<'pem' | 'jwks'>('pem')
let guestJwtPublicKey: string = $state('')
let guestJwtJwksUrl: string = $state('')
let initialGuestJwtPublicKey: string = $state('')
let initialGuestJwtJwksUrl: string = $state('')
// The pair actually saved: only the selected type's field, trimmed. The unselected
// one is empty, so switching type and saving clears what was there.
let effectiveGuestJwt = $derived({
pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '',
jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : ''
})
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
let hasInstanceAiConfig = $state(false)
@@ -526,11 +539,17 @@
}
async function saveDefaultAppSettings(): Promise<void> {
// Guests first: the only write of this card available on every plan, so a refused
// Enterprise-only write after it cannot swallow it.
// Guest access and the guest JWT key are the writes of this card available on every plan;
// save them first so a refused Enterprise-only write after cannot swallow them.
if (guestAccessEnabled !== initialGuestAccessEnabled) {
await editGuestAccess()
}
if (
effectiveGuestJwt.pem !== initialGuestJwtPublicKey ||
effectiveGuestJwt.jwks !== initialGuestJwtJwksUrl
) {
await editGuestJwtKey()
}
if (workspaceDefaultAppPath !== initialWorkspaceDefaultAppPath) {
await editWorkspaceDefaultApp()
}
@@ -539,6 +558,19 @@
}
}
async function editGuestJwtKey(): Promise<void> {
await WorkspaceService.editGuestJwtKey({
workspace: $workspaceStore!,
requestBody: {
public_key: effectiveGuestJwt.pem || undefined,
jwks_url: effectiveGuestJwt.jwks || undefined
}
})
initialGuestJwtPublicKey = effectiveGuestJwt.pem
initialGuestJwtJwksUrl = effectiveGuestJwt.jwks
sendUserToast('Guest JWT key updated')
}
async function editGuestAccess(): Promise<void> {
await WorkspaceService.editGuestAccess({
workspace: $workspaceStore!,
@@ -647,6 +679,11 @@
initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
guestAccessEnabled = settings.guest_access_enabled ?? false
initialGuestAccessEnabled = settings.guest_access_enabled ?? false
guestJwtPublicKey = settings.guest_jwt_public_key ?? ''
guestJwtJwksUrl = settings.guest_jwt_jwks_url ?? ''
initialGuestJwtPublicKey = guestJwtPublicKey
initialGuestJwtJwksUrl = guestJwtJwksUrl
guestJwtKeyType = guestJwtJwksUrl ? 'jwks' : 'pem'
WorkspaceService.getGuestUsage({ workspace: $workspaceStore! })
.then((u) => (guestUsage = u))
.catch(() => (guestUsage = undefined))
@@ -1052,12 +1089,16 @@
savedValue: {
defaultAppPath: initialWorkspaceDefaultAppPath,
publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute,
guestAccessEnabled: initialGuestAccessEnabled
guestAccessEnabled: initialGuestAccessEnabled,
guestJwtPem: initialGuestJwtPublicKey,
guestJwtJwks: initialGuestJwtJwksUrl
},
modifiedValue: {
defaultAppPath: workspaceDefaultAppPath,
publicAppRateLimitPerMinute: publicAppRateLimitPerMinute,
guestAccessEnabled: guestAccessEnabled
guestAccessEnabled: guestAccessEnabled,
guestJwtPem: effectiveGuestJwt.pem,
guestJwtJwks: effectiveGuestJwt.jwks
}
}
}
@@ -1067,6 +1108,9 @@
workspaceDefaultAppPath = initialWorkspaceDefaultAppPath
publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute
guestAccessEnabled = initialGuestAccessEnabled
guestJwtPublicKey = initialGuestJwtPublicKey
guestJwtJwksUrl = initialGuestJwtJwksUrl
guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem'
}
// Strip keys from extraArgs that are auto-managed by child components:
@@ -2184,7 +2228,7 @@ export async function main(
<SettingCard
label="Guests"
description="Let anyone your identity provider authenticates open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
description="Let anyone your identity provider authenticates, or a JWT your own backend signs (configured below), open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
class="mt-6"
>
<Toggle
@@ -2198,8 +2242,8 @@ export async function main(
</span>
{:else if guestUsage}
<span class="text-hint text-2xs">
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across
this instance in the last {guestUsage.window_days} days.
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
instance in the last {guestUsage.window_days} days.
{#if guestUsage.metered}
Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0
? ` (${guestUsage.guest_seats} now)`
@@ -2210,6 +2254,54 @@ export async function main(
{/if}
</span>
{/if}
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="text-xs font-semibold text-emphasis">
Guest JWT verification key
</div>
<div class="text-2xs text-hint">
A guest can also enter through a JWT your own backend mints and signs, with no
identity-provider round-trip, for iframe embedding. The token must carry
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
<code>app_path</code>. Accepted algorithms: RS256/384/512, PS256/384/512,
ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM
public key or a JWKS URL (which must be https). Point it at an issuer you
control: any token that key signs carrying these claims is accepted, so a shared
multi-tenant issuer is not a good fit.
</div>
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
{#snippet children({ item })}
<ToggleButton {item} value="pem" label="PEM public key" />
<ToggleButton {item} value="jwks" label="JWKS URL" />
{/snippet}
</ToggleButtonGroup>
{#if guestJwtKeyType === 'pem'}
<TextInput
underlyingInputEl="textarea"
class="font-mono text-xs"
autosizeParams={{ minHeight: 128 }}
inputProps={{
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
}}
bind:value={guestJwtPublicKey}
/>
{:else}
<TextInput
inputProps={{
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
}}
bind:value={guestJwtJwksUrl}
/>
{/if}
{#if !isCloudHosted()}
<div class="text-2xs text-hint">
Leave empty to fall back to the instance's configured JWT issuer (<code
>JWT_EXT_JWKS_URL</code
>), if one is set. Set a key here to trust a different issuer for this
workspace.
</div>
{/if}
</div>
</SettingCard>
<SettingsFooter
@@ -2218,7 +2310,10 @@ export async function main(
onSave={saveDefaultAppSettings}
onDiscard={discardDefaultAppSettingsChanges}
saveLabel="Save app settings"
disabled={!$enterpriseLicense && guestAccessEnabled === initialGuestAccessEnabled}
disabled={!$enterpriseLicense &&
guestAccessEnabled === initialGuestAccessEnabled &&
effectiveGuestJwt.pem === initialGuestJwtPublicKey &&
effectiveGuestJwt.jwks === initialGuestJwtJwksUrl}
/>
{:else if tab == 'native_triggers'}
{#if $workspaceStore}
+4 -1
View File
@@ -147,7 +147,10 @@
} else {
if (
(!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) &&
!page.url.pathname.startsWith('/oauth/mcp_authorize') &&
// The MCP consent page carries its own workspace picker, so it is left to
// run without one. Nothing sets `$userStore` on this branch, which is why
// that page must stay outside the (logged) layout — see its `@(root)` name.
!page.url.pathname.startsWith(`${base}/oauth/mcp_authorize`) &&
// The hub import wizard asks for the destination itself, and may end in a
// workspace that does not exist yet — bouncing it to the picker would
// force the very choice it exists to make.
+30 -12
View File
@@ -19,30 +19,46 @@
let jwtError = $state(false)
function isJwt(t: string) {
// simply check that the first part is a valid base64 encoded json
// A JWT is three dot-separated base64url segments; check the header decodes to
// JSON. `atob` wants standard base64, so normalise base64url first (a `kid` or a
// signature routinely contains `-`/`_`), or a valid token is taken for a path.
try {
const parts = t.split('.')
const header = atob(parts[0])
JSON.parse(header)
if (parts.length !== 3) return false
const b64 = parts[0].replace(/-/g, '+').replace(/_/g, '/')
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
JSON.parse(atob(b64 + pad))
return true
} catch (e) {
return false
}
}
function parseCustomPath(customPath: string): { path: string; jwt: string | undefined } {
// The custom path may carry a trailing credential: an external JWT as its last
// segment, or a guest JWT in a `guest.<jwt>` last segment (`<path>/guest.<jwt>`). The
// `guest.` prefix keeps the two apart; `viewerUrl` uses `path` alone, so neither
// reaches the opaque iframe.
function parseCustomPath(customPath: string): {
path: string
jwt: string | undefined
guestJwt: string | undefined
} {
const parts = customPath.split('/')
if (parts.length > 1 && isJwt(parts[parts.length - 1])) {
const last = parts[parts.length - 1]
// A guest JWT rides the last segment prefixed `guest.`. The `.` means it can never
// be a valid custom-path segment, so a real path ending in a `guest` segment
// followed by an external JWT (`.../guest/<jwt>`) is read as before, not hijacked.
if (last.startsWith('guest.') && isJwt(last.slice('guest.'.length))) {
return {
path: parts.slice(0, -1).join('/'),
jwt: parts[parts.length - 1]
}
} else {
return {
path: customPath,
jwt: undefined
jwt: undefined,
guestJwt: last.slice('guest.'.length)
}
}
if (parts.length > 1 && isJwt(last)) {
return { path: parts.slice(0, -1).join('/'), jwt: last, guestJwt: undefined }
}
return { path: customPath, jwt: undefined, guestJwt: undefined }
}
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
@@ -102,7 +118,9 @@
// Embedder side: validate access (main session cookie or shared JWT) and mint
// a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
if (parsedCustomPath.jwt) {
if (parsedCustomPath.guestJwt) {
OpenAPI.TOKEN = 'jwt_guest_' + parsedCustomPath.guestJwt
} else if (parsedCustomPath.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
}
const headers: Record<string, string> = {}
@@ -27,12 +27,25 @@
* offering an ordinary sign-in on a transient fault would provision an account. */
let guestEntry: 'pending' | 'none' | 'guest' | 'error' = $state('pending')
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
// The share link carries a trailing credential the embedder consumes: an external
// JWT as `<secret>/<jwt>`, or a guest JWT as `<secret>/guest.<jwt>`. The `guest.`
// prefix keeps the two apart with no parsing of the token, which the page cannot
// verify anyway. Either way `viewerUrl` below uses `secret` alone, so no JWT
// reaches the opaque iframe.
function parseSecret(secret: string): {
secret: string
jwt: string | undefined
guestJwt: string | undefined
} {
const parts = secret.split('/')
return {
secret: parts[0],
jwt: parts[1]
// The credential rides the segment after the secret: a guest JWT prefixed
// `guest.`, or an external JWT bare. The `guest.` prefix glues the marker to the
// token, so it can never be mistaken for a path or secret segment (which carry no
// `.`), and a bare token keeps the established external-JWT interpretation.
if (parts[1]?.startsWith('guest.')) {
return { secret: parts[0], jwt: undefined, guestJwt: parts[1].slice('guest.'.length) }
}
return { secret: parts[0], jwt: parts[1], guestJwt: undefined }
}
const parsedSecret = parseSecret(page.params.secret ?? '')
@@ -52,7 +65,9 @@
// Embedder side: validate access (using the main session cookie or the shared
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
if (parsedSecret.jwt) {
if (parsedSecret.guestJwt) {
OpenAPI.TOKEN = 'jwt_guest_' + parsedSecret.guestJwt
} else if (parsedSecret.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
}
const headers: Record<string, string> = {}
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.803.0"
wmill = ">=1.804.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.803.0
version: 1.804.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel

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