Merge branch 'main' into change-50d49bad

This commit is contained in:
Diego Imbert
2026-09-05 16:06:08 +02:00
committed by GitHub
199 changed files with 11515 additions and 2017 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"
}
@@ -0,0 +1,50 @@
{
"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 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": [
{
"ordinal": 0,
"name": "since",
"type_info": "Date"
},
{
"ordinal": 1,
"name": "guest_count!",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "guest_jwt_count!",
"type_info": "Int4"
},
{
"ordinal": 3,
"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"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null,
null,
null
]
},
"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,21 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Text",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1"
}
@@ -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 guest_activity (email, workspace_id, day)\n VALUES ($1, $2, CURRENT_DATE)\n ON CONFLICT (email, workspace_id, day)\n DO UPDATE SET last_seen_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5"
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM app WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "391139a04bd48319a5512e7859b63e81438c7483ac892b971fe8a20709555cc1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477"
}
@@ -1,6 +1,6 @@
{
"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) 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 FROM workspace_settings WHERE workspace_id = $2",
"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": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007"
"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,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n RETURNING token_prefix",
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR label <> 'guest_session')\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726"
"hash": "d17645b5001d7f8da1dc451c5d35ea3c9346271b8404863256071cfdf884036a"
}
@@ -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"
}
@@ -0,0 +1,226 @@
{
"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 guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "slack_team_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "teams_team_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "teams_team_name",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "teams_team_guid",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "slack_name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "slack_command_script",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "teams_command_script",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "slack_email",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "slack_oauth_client_id",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "slack_oauth_client_secret",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "customer_id",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "plan",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "webhook",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "ai_config",
"type_info": "Jsonb"
},
{
"ordinal": 15,
"name": "dbt_warehouses",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 17,
"name": "datatable",
"type_info": "Jsonb"
},
{
"ordinal": 18,
"name": "ducklake",
"type_info": "Jsonb"
},
{
"ordinal": 19,
"name": "git_sync",
"type_info": "Jsonb"
},
{
"ordinal": 20,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 21,
"name": "default_app",
"type_info": "Varchar"
},
{
"ordinal": 22,
"name": "default_scripts",
"type_info": "Jsonb"
},
{
"ordinal": 23,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 24,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 25,
"name": "operator_settings",
"type_info": "Jsonb"
},
{
"ordinal": 26,
"name": "git_app_installations",
"type_info": "Jsonb"
},
{
"ordinal": 27,
"name": "auto_invite",
"type_info": "Jsonb"
},
{
"ordinal": 28,
"name": "error_handler",
"type_info": "Jsonb"
},
{
"ordinal": 29,
"name": "success_handler",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"name": "public_app_execution_limit_per_minute",
"type_info": "Int4"
},
{
"ordinal": 31,
"name": "error_handler_fallback_to_instance_alerts",
"type_info": "Bool"
},
{
"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": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
false,
false,
true,
true
]
},
"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"
}
@@ -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,82 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "slack_team_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "slack_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "teams_team_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "teams_team_name",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "teams_team_guid",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "guest_access_enabled",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "datatable",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
}
@@ -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"
}
+199 -194
View File
File diff suppressed because it is too large Load Diff
+7 -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"
@@ -351,6 +351,8 @@ windmill-trigger-sqs.workspace = true
windmill-trigger-gcp.workspace = true
windmill-trigger-azure.workspace = true
windmill-api-auth.workspace = true
tower-cookies.workspace = true
windmill-api-users.workspace = true
axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
@@ -365,6 +367,7 @@ aws-config.workspace = true
aws-credential-types.workspace = true
hmac.workspace = true
hex.workspace = true
jsonwebtoken = { workspace = true }
[workspace.dependencies]
@@ -597,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 @@
d6297e6844dc2aab4745fce328e32ccab508969f
313c572c9dcbcaafd8a1594df4054f9dd26f395c
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS guest_activity;
ALTER TABLE workspace_settings DROP COLUMN guest_access_enabled;
@@ -0,0 +1,28 @@
-- Guest app access: a workspace-level switch, off by default. An app whose policy says
-- `execution_mode: guest` only admits guests where this is on, and the check runs where
-- the guest session is minted -- an app definition carries its policy, so git-sync and
-- the CLI push `guest` past every UI gate.
ALTER TABLE workspace_settings
ADD COLUMN guest_access_enabled BOOLEAN NOT NULL DEFAULT false;
-- A guest leaves no `usr` or `password` row, which is what keeps them off every seat
-- counter, so this is the only durable record that one was here: a row per guest,
-- workspace and day, written when the session is minted.
--
-- Deliberately not the audit log. The seat scan is served by a partial index whose
-- predicate names the login operations literally, and `audit_partitioned` is a
-- partitioned table, where `CREATE INDEX CONCURRENTLY` is unsupported -- adding a
-- guest operation to that predicate means a locking rebuild on the largest table an
-- instance has. Guest logins still write `users.login_guest` for the audit trail;
-- nothing counts them from there.
CREATE TABLE guest_activity (
email VARCHAR(255) NOT NULL,
workspace_id VARCHAR(50) NOT NULL,
day DATE NOT NULL DEFAULT CURRENT_DATE,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (email, workspace_id, day)
);
-- The retention delete filters on day alone; the PK only reaches it through two
-- other columns.
CREATE INDEX idx_guest_activity_day ON guest_activity (day);
@@ -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;
+41
View File
@@ -29,6 +29,10 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
"scope_options": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/spreadsheets.readonly"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
@@ -38,6 +42,11 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/drive"],
"scope_options": [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/drive"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
@@ -47,6 +56,13 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/gmail.send"],
"scope_options": [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
@@ -56,6 +72,12 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/calendar.events"],
"scope_options": [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.events.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
@@ -65,6 +87,12 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/forms"],
"scope_options": [
"https://www.googleapis.com/auth/forms",
"https://www.googleapis.com/auth/forms.body",
"https://www.googleapis.com/auth/forms.body.readonly",
"https://www.googleapis.com/auth/forms.responses.readonly"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
@@ -74,6 +102,10 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/cloud-platform"],
"scope_options": [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/cloud-platform.read-only"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
@@ -88,6 +120,15 @@
"https://www.googleapis.com/auth/admin.directory.user.security",
"https://www.googleapis.com/auth/admin.directory.orgunit"
],
"scope_options": [
"https://www.googleapis.com/auth/admin.directory.user",
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.group",
"https://www.googleapis.com/auth/admin.directory.group.readonly",
"https://www.googleapis.com/auth/admin.directory.orgunit",
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
"https://www.googleapis.com/auth/admin.directory.user.security"
],
"extra_params": {
"access_type": "offline",
"prompt": "consent"
+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>"]
+9
View File
@@ -1937,6 +1937,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::error!("Error deleting old feature_usage rows: {e}");
}
// Guest sign-ins, kept a month longer than the seat window they feed so a late
// telemetry send still sees a whole month.
if let Err(e) = sqlx::query!("DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60")
.execute(db)
.await
{
tracing::error!("Error deleting old guest_activity rows: {e}");
}
match sqlx::query_scalar!(
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
)
+2 -1
View File
@@ -110,6 +110,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), 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)
@@ -222,7 +223,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)
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)
+159
View File
@@ -0,0 +1,159 @@
//! The guest allowance: free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the
//! trailing window. Past it, a hard-capped instance (Community, Pro) refuses a stranger
//! and lets a returning guest back in; a metered one (Enterprise) admits everyone and
//! counts seats. Its own binary: the plan is read from a process-wide key id that this
//! test flips, which no test sharing the process could tolerate.
//!
//! 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, GUEST_WINDOW_DAYS};
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` (the key id) and `enterprise` (the plan read) 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;
}
async fn mint(db: &Pool<Postgres>, email: &str) -> windmill_common::error::Result<String> {
let mut tx = db.begin().await.unwrap();
let minted = windmill_api_users::users::create_guest_session_token(
email,
"test-workspace",
APP_PATH,
&mut tx,
tower_cookies::Cookies::default(),
)
.await;
tx.commit().await.unwrap();
minted
}
#[sqlx::test(fixtures("base"))]
async fn the_allowance_caps_strangers_and_meters_an_enterprise_plan(
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}/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 yesterday: still in the window, and a day the mint
// does not write, so a row dated today can only be the mint's own.
sqlx::query(
"INSERT INTO guest_activity (email, workspace_id, day)
SELECT 'g' || i || '@example.com', 'test-workspace', CURRENT_DATE - 1
FROM generate_series(1, $1) AS i",
)
.bind(FREE_GUESTS_PER_WINDOW)
.execute(&db)
.await?;
set_plan(true);
let refused = mint(&db, "stranger@example.com").await.unwrap_err();
assert!(
matches!(&refused, windmill_common::error::Error::PermissionDenied(m)
if m.contains(&format!("limit of {FREE_GUESTS_PER_WINDOW} guests over {GUEST_WINDOW_DAYS} days"))),
"a stranger past the allowance is refused with the message the visitor reads: {refused:?}"
);
mint(&db, "g1@example.com")
.await
.expect("a guest already in the window is let back in");
let recorded: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guest_activity
WHERE email = 'g1@example.com' AND workspace_id = 'test-workspace'
AND day = CURRENT_DATE)",
)
.fetch_one(&db)
.await?;
assert!(
recorded,
"the mint writes today's guest_activity row, the allowance's unit"
);
let list: serde_json::Value = authed(
client().get(format!(
"http://localhost:{port}/api/users/guests?per_page=5"
)),
ADMIN_TOKEN,
)
.send()
.await?
.json()
.await?;
assert_eq!(list["usage"]["guest_count"], FREE_GUESTS_PER_WINDOW);
assert_eq!(list["usage"]["metered"], false);
assert_eq!(list["usage"]["guest_seats"], 0);
assert_eq!(list["guests"].as_array().map(Vec::len), Some(5));
assert_eq!(list["guests"][0]["workspaces"], json!(["test-workspace"]));
let usage: serde_json::Value = authed(
client().get(format!("{ws}/workspaces/guest_usage")),
ADMIN_TOKEN,
)
.send()
.await?
.json()
.await?;
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW);
#[cfg(all(feature = "private", feature = "enterprise"))]
{
set_plan(false);
mint(&db, "stranger@example.com")
.await
.expect("a metered plan admits past the allowance");
let usage: serde_json::Value = authed(
client().get(format!("{ws}/workspaces/guest_usage")),
ADMIN_TOKEN,
)
.send()
.await?
.json()
.await?;
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW + 1);
assert_eq!(usage["metered"], true);
assert_eq!(usage["billable_guests"], 1);
assert_eq!(
usage["guest_seats"], 1,
"one guest past the allowance is a whole seat"
);
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+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,
}
}
+62 -38
View File
@@ -1,3 +1,7 @@
// These pin language-independent behaviour, and Python is the cheapest runtime that still
// generates a real lock out of relative imports, so the whole file needs that feature.
#![cfg(feature = "python")]
use sqlx::{Pool, Postgres};
use tokio_stream::StreamExt;
use windmill_api_client::types::NewScript;
@@ -6,21 +10,27 @@ use windmill_test_utils::*;
const W: &str = "test-workspace";
const A: &str = r#"export async function main() { return "a" }"#;
const A_COMMENTED: &str = r#"// same dependencies, different content
export async function main() { return "a" }"#;
const A_WITH_LODASH: &str = r#"import _ from "lodash@4.17.21";
export async function main() { return _.trim(" a ") }"#;
const B: &str = r#"import { main as a } from "/f/rel/a.ts";
export async function main() { return "b" + (await a()) }"#;
const C: &str = r#"import { main as b } from "/f/rel/b.ts";
export async function main() { return "c" + (await b()) }"#;
/// Budget for one wait below, counted completions and drain together. Even against a cold cache
/// these settle in a few seconds, so it only bounds a step that is stuck, and it stays under the
/// 60s cap `in_test_worker` puts on the whole body so the panic names what was being waited on
/// rather than surfacing as a worker timeout.
const WAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(30);
fn bun_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
/// How often the waits below re-check. The drain reads the queue once per completion it waits
/// this long for, so it doubles as the floor on one turn of that loop.
const POLL: std::time::Duration = std::time::Duration::from_millis(20);
const A: &str = "def main():\n return 'a'\n";
const A_COMMENTED: &str = "# same dependencies, different content\ndef main():\n return 'a'\n";
const A_WITH_TINY: &str = "import tiny\n\ndef main():\n return 'a'\n";
const B: &str = "from f.rel.a import main as a\n\ndef main():\n return 'b' + a()\n";
const C: &str = "from f.rel.b import main as b\n\ndef main():\n return 'c' + b()\n";
fn py_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
NewScript {
draft_only: None,
content: content.into(),
language: windmill_api_client::types::ScriptLang::Bun,
language: windmill_api_client::types::ScriptLang::Python3,
lock: None,
parent_hash,
path: path.into(),
@@ -96,17 +106,33 @@ async fn dependency_jobs_since(
}
async fn wait_for_jobs(
db: &Pool<Postgres>,
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
count: usize,
) {
for _ in 0..count {
completed.next().await;
let deadline = tokio::time::Instant::now() + WAIT_BUDGET;
for i in 0..count {
tokio::time::timeout_at(deadline, completed.next())
.await
.unwrap_or_else(|_| panic!("only {i} of {count} jobs completed"));
}
// Then let anything else that was queued run out, so a job the assertions say must not
// exist would have shown up here.
while let Ok(Some(_)) =
tokio::time::timeout(std::time::Duration::from_secs(2), completed.next()).await
{}
// exist would have shown up here. A dependency job queues its fan-out before it completes,
// so an empty queue is a fixpoint rather than a lull.
loop {
while let Ok(Some(_)) = tokio::time::timeout(POLL, completed.next()).await {}
let queued: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job_queue")
.fetch_one(db)
.await
.unwrap();
if queued == 0 {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"the queue never emptied"
);
}
}
/// A redeploy of an imported script whose dependencies did not move relocks its importer,
@@ -128,10 +154,10 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
// importer whose edges are recorded is what a later relock of it can skip on.
for (path, content) in [("f/rel/a", A), ("f/rel/b", B), ("f/rel/c", C)] {
client
.create_script(W, &bun_script(path, content, None))
.create_script(W, &py_script(path, content, None))
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
wait_for_jobs(&db, &mut completed, 1).await;
}
let b_before = versions(&db, "f/rel/b").await;
let c_before = versions(&db, "f/rel/c").await;
@@ -144,11 +170,11 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
client
.create_script(
W,
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
&py_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
wait_for_jobs(&mut completed, 2).await;
wait_for_jobs(&db, &mut completed, 2).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
@@ -181,11 +207,11 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
client
.create_script(
W,
&bun_script("f/rel/a", A_WITH_LODASH, Some(format!("{a_hash:016x}"))),
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
wait_for_jobs(&mut completed, 3).await;
wait_for_jobs(&db, &mut completed, 3).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
@@ -203,7 +229,7 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
);
assert!(vs[0].created_at < vs[1].created_at, "{path}: lineage order");
assert!(
vs[1].lock.as_deref().unwrap_or("").contains("lodash"),
vs[1].lock.as_deref().unwrap_or("").contains("tiny"),
"{path}: the new version carries the new lock: {:?}",
vs[1].lock
);
@@ -233,10 +259,10 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
async {
for (path, content) in [("f/rel/a", A), ("f/rel/b", B)] {
client
.create_script(W, &bun_script(path, content, None))
.create_script(W, &py_script(path, content, None))
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
wait_for_jobs(&db, &mut completed, 1).await;
}
// A deploy of b that holds its head's row lock for as long as this transaction lives.
@@ -251,14 +277,15 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
client
.create_script(
W,
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
&py_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
// b's relock skips generation and reaches its commit, where it waits on the lock.
let deadline = std::time::Instant::now() + WAIT_BUDGET;
let mut waiting = false;
for _ in 0..300 {
while !waiting && std::time::Instant::now() < deadline {
waiting = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock'
@@ -267,10 +294,9 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
.fetch_one(&db)
.await
.unwrap();
if waiting {
break;
if !waiting {
tokio::time::sleep(POLL).await;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
assert!(waiting, "b's relock never reached the row lock");
@@ -284,7 +310,7 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
deploy.commit().await.unwrap();
// a's own job, the relock that waited, and the relock it queued for the successor.
wait_for_jobs(&mut completed, 3).await;
wait_for_jobs(&db, &mut completed, 3).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
@@ -324,7 +350,6 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
/// A multi-file importer: on a skipped relock each module gets its own last lock back, not the
/// parent script's, so an import's content-only redeploy leaves the importer alone as well.
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
@@ -332,8 +357,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
let mut completed = listen_for_completed_jobs(&db).await;
let py = |path: &str, content: &str, parent_hash: Option<String>, with_module: bool| {
let mut ns = bun_script(path, content, parent_hash);
ns.language = windmill_api_client::types::ScriptLang::Python3;
let mut ns = py_script(path, content, parent_hash);
if with_module {
ns.modules = Some(std::collections::HashMap::from([(
"helper.py".to_string(),
@@ -363,7 +387,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
.create_script(W, &py("f/rel/pa", "def main():\n return 'a'\n", None, false))
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
wait_for_jobs(&db, &mut completed, 1).await;
client
.create_script(
W,
@@ -376,7 +400,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
)
.await
.unwrap();
wait_for_jobs(&mut completed, 1).await;
wait_for_jobs(&db, &mut completed, 1).await;
let lock_before = module_lock(&db).await;
assert!(lock_before.is_some(), "the module got a lock of its own on deploy");
@@ -394,7 +418,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
)
.await
.unwrap();
wait_for_jobs(&mut completed, 2).await;
wait_for_jobs(&db, &mut completed, 2).await;
let jobs = dependency_jobs_since(&db, since).await;
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
@@ -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,
}
}
@@ -33,6 +33,7 @@ fn outsider() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
+307 -5
View File
@@ -138,12 +138,28 @@ impl AuthCache {
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id, token).await?;
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id.clone(), token).await?;
// Single source of truth: mirror the resolved job_id onto the authed so
// every consumer (require_super_admin, ...) sees that this identity came
// from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed
// predates this field.
opt_job_authed.authed.job_id = opt_job_authed.job_id;
// The workspace's guest switch is enforced here, once, for every guest request
// — not per handler, where each guest-reachable route would have to remember
// it. Uncached, so turning guests off takes effect on the next request of every
// guest session and every token derived from one.
if crate::scopes::has_guest_sentinel(opt_job_authed.authed.scopes.as_deref()) {
let Some(w_id) = w_id else { return None };
let email = &opt_job_authed.authed.email;
match windmill_common::workspaces::guest_session_stands(&self.db, &w_id, email).await {
Ok(true) => {}
Ok(false) => return None,
Err(e) => {
tracing::error!("guest session check failed for {w_id}: {e:#}");
return None;
}
}
}
Some(opt_job_authed)
}
@@ -159,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(),
@@ -200,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_");
@@ -233,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
@@ -347,6 +481,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
} else {
tracing::warn!(
@@ -400,6 +535,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
} else {
tracing::warn!(
@@ -427,6 +563,8 @@ impl AuthCache {
}
(_, Some(email), super_admin, scopes, label, read_only) => {
let is_session_token = is_session_label(label.as_deref());
let is_guest_session =
windmill_common::auth::is_guest_session_label(label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(label);
if w_id.is_some() {
@@ -476,6 +614,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
None if super_admin => {
@@ -500,6 +639,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
}),
Err(e) => {
tracing::error!(
@@ -509,6 +649,37 @@ impl AuthCache {
}
}
}
// A guest session: IdP-authenticated, member of
// nothing. No `usr` lookup, groups or folders, so
// every ACL denies it and the token's scopes are
// its whole grant. After the superadmin arm, so
// that token is never demoted into this one.
None if is_guest_session => {
// The server-minted label is the grant, never
// the `guest` scope (a user-minted token's
// scopes are whatever the caller typed); the
// sentinel is pinned on here so every guest
// control downstream sees a guest regardless.
let scopes = Some(crate::scopes::with_guest_sentinel(
scopes.unwrap_or_default(),
));
Some(ApiAuthed {
username: email.clone(),
email,
is_admin: false,
is_operator: true,
groups: vec![],
folders: vec![],
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
None => None,
}
} else {
@@ -526,6 +697,7 @@ impl AuthCache {
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
credential_expiry: None,
})
}
}
@@ -564,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 {
@@ -574,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
@@ -774,6 +1068,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
@@ -934,10 +1229,17 @@ pub(crate) fn username_override_from_label(label: Option<String>) -> (Option<Str
{
(Some(label), true)
}
Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => (
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
true,
),
Some(label)
if label != "ephemeral-script"
&& label != "session"
&& label != windmill_common::auth::GUEST_SESSION_LABEL
&& !label.is_empty() =>
{
(
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
true,
)
}
_ => (None, false),
}
}
+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(
+80
View File
@@ -500,6 +500,24 @@ pub fn check_route_access(
}
}
// A guest session carries the same broad read scopes as an embed token and for
// the same handful of routes, so it gets the same default-deny.
if has_guest_sentinel(Some(token_scopes)) {
if let Some(suffix) = route_suffix.as_deref() {
if guest_route_denied(required_domain, suffix) {
return Err(Error::PermissionDenied(format!(
"a guest session cannot access {route_path}"
)));
}
// Same rationale as the embed branch: re-running a component supersedes
// its in-flight run, and `cancel_job_api` confines this to the caller's
// own jobs.
if suffix.starts_with("jobs_u/queue/cancel/") {
return Ok(());
}
}
}
// Each declared scope must grant what its prompt said and no more:
// `jobs:run` only deployed runnables, `users:read` only the viewer's identity.
if has_raw_app_sdk_sentinel(Some(token_scopes)) {
@@ -753,6 +771,55 @@ pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
}
/// Sentinel in a guest session token: someone the identity provider authenticated
/// who is a member of no workspace. Grants nothing itself — it only confines the
/// session to the app surface, the same way `app_embed` does. What makes a session a
/// guest at all is the server-minted label
/// [`windmill_common::auth::GUEST_SESSION_LABEL`]; a forged sentinel here can only
/// narrow its own token.
pub const GUEST_SENTINEL: &str = "guest";
/// True if a token is a guest session, whose scopes are its entire grant: it has no ACL
/// of its own, so every ACL check denies it unaided.
pub fn has_guest_sentinel(scopes: Option<&[String]>) -> bool {
scopes.is_some_and(|s| s.iter().any(|x| x == GUEST_SENTINEL))
}
/// `scopes` with the guest sentinel present exactly once.
pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
if !scopes.iter().any(|x| x == GUEST_SENTINEL) {
scopes.push(GUEST_SENTINEL.to_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";
@@ -815,6 +882,19 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool {
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
}
/// Routes a guest session is denied: the app-embed allowlist, plus the embed-token
/// mint. A guest session is the *embedder* — the viewer's own browser rendering the
/// app page — not the app's own JS, and the page mints the iframe's token from it.
///
/// Everything else stays default-denied, so a guest reaches the app it was let in
/// for and nothing around it.
fn guest_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
if domain == ScopeDomain::Apps && suffix.starts_with("apps_u/embed_token") {
return false;
}
app_embed_route_denied(domain, suffix)
}
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
@@ -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,
}
}
+148 -6
View File
@@ -657,15 +657,17 @@ async fn logout(
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT {
sqlx::query_scalar!(
// A guest's browser session is a session too: this is its one user-driven revocation.
sqlx::query_scalar::<_, Option<String>>(
"WITH email_lookup AS (
SELECT email FROM token WHERE token_hash = $1
)
DELETE FROM token
WHERE email = (SELECT email FROM email_lookup) AND label = 'session'
WHERE email = (SELECT email FROM email_lookup)
AND label IN ('session', 'guest_session')
RETURNING email",
t_hash
)
.bind(&t_hash)
.fetch_optional(&mut *tx)
.await?
} else {
@@ -745,7 +747,30 @@ async fn whoami(
Path(w_id): Path<String>,
authed: ApiAuthed,
) -> JsonResult<UserInfo> {
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
// A guest would otherwise fall through to the non-member branch below and be
// handed a `superadmin` role. Answer it here, as the operator-shaped identity it is.
if is_guest {
return Ok(Json(UserInfo {
workspace_id: w_id,
email,
username,
name: None,
is_admin: false,
is_super_admin: false,
created_at: chrono::Utc::now(),
groups: vec![],
operator: true,
disabled: false,
role: Some("guest".to_string()),
folders_read: vec![],
folders: vec![],
folders_owners: vec![],
is_service_account: false,
non_member: true,
}));
}
let user = get_user(&w_id, &username, &db).await?;
// Only treat the row as "this user is a member" when its email matches; the
// derived username is instance-unique so a match on a different email should
@@ -2882,7 +2907,12 @@ pub async fn create_session_token<'c>(
.execute(&mut **tx)
.await?;
let mut cookie = Cookie::new(COOKIE_NAME, token.clone());
set_session_cookie(&cookies, &token, *MAX_SESSION_VALIDITY_SECONDS);
Ok(token)
}
fn set_session_cookie(cookies: &Cookies, token: &str, validity_seconds: i64) {
let mut cookie = Cookie::new(COOKIE_NAME, token.to_string());
cookie.set_secure(IS_SECURE.load(std::sync::atomic::Ordering::Relaxed));
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
cookie.set_http_only(true);
@@ -2892,9 +2922,116 @@ pub async fn create_session_token<'c>(
}
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
expire += time::Duration::seconds(*MAX_SESSION_VALIDITY_SECONDS);
expire += time::Duration::seconds(validity_seconds);
cookie.set_expires(expire);
cookies.add(cookie);
}
lazy_static::lazy_static! {
/// A guest session is the only credential held by someone with no account, so
/// there is nothing to disable when the workspace revokes guest access or the
/// identity provider removes them — the expiry is the revocation. Much shorter
/// than a member session for that reason.
static ref GUEST_SESSION_VALIDITY_SECONDS: i64 = std::env::var("GUEST_SESSION_VALIDITY_SECONDS")
.ok()
.and_then(|x| x.parse::<i64>().ok())
.unwrap_or(8 * 60 * 60);
}
/// 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
/// nothing here may be "helpfully" upgraded into provisioning.
///
/// Pinned to `w_id` (`AuthCache` matches on `token.workspace_id`): without the pin an
/// `apps:run:<path>` scope would unlock a same-path app elsewhere. So a guest cannot
/// authenticate on any workspace-less route (`/api/users/*`, `/api/settings/*`); a
/// page that needs one for a guest must become workspace-scoped, not loosen the pin.
///
/// Refuses unless every gate says yes (`guest_app_admits`, then the allowance in
/// `guest_admission`), so no caller can mint where a guest is not wanted, whatever it
/// believed when it decided to call. All that is left to the caller is the
/// authentication of `email`.
pub async fn create_guest_session_token<'c>(
email: &str,
w_id: &str,
app_path: &str,
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
cookies: Cookies,
) -> Result<String> {
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
let token = rd_string(32);
let t_hash = windmill_common::auth::hash_token(&token);
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
None
} else {
Some(&token)
};
let scopes = windmill_api_auth::scopes::guest_session_scopes(app_path)?;
// 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(),
));
}
if !windmill_common::workspaces::guest_app_admits(&mut **tx, w_id, app_path).await? {
return Err(Error::NotAuthorized(format!(
"app {app_path} is not open to guests"
)));
}
windmill_common::workspaces::guest_admission(&mut **tx, email).await?;
sqlx::query!(
"INSERT INTO token
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
t_hash,
t_prefix,
plaintext as Option<&str>,
email,
windmill_common::auth::GUEST_SESSION_LABEL,
&GUEST_SESSION_VALIDITY_SECONDS.to_string(),
&scopes,
w_id,
)
.execute(&mut **tx)
.await?;
// The only durable record that a guest was here, and the set the allowance is
// counted on; not the audit log, see the migration. Idempotent per email,
// workspace and day.
sqlx::query!(
"INSERT INTO guest_activity (email, workspace_id, day)
VALUES ($1, $2, CURRENT_DATE)
ON CONFLICT (email, workspace_id, day)
DO UPDATE SET last_seen_at = now()",
email,
w_id,
)
.execute(&mut **tx)
.await?;
audit_log(
&mut **tx,
&AuditAuthor {
email: email.to_string(),
username: email.to_string(),
username_override: None,
token_prefix: Some(t_prefix.to_string()),
},
"users.login_guest",
ActionKind::Create,
w_id,
Some(app_path),
Some([("entry", "idp")].into()),
)
.await?;
set_session_cookie(&cookies, &token, *GUEST_SESSION_VALIDITY_SECONDS);
Ok(token)
}
@@ -3159,9 +3296,13 @@ async fn update_token_scopes(
let mut tx = db.begin().await?;
// A guest-labelled token is never rescoped: its scopes are its whole confinement,
// and after promotion the same email owns an account that could otherwise strip
// them from the still-valid guest credential. Same shape as the relabel guard.
let updated: Option<String> = sqlx::query_scalar!(
"UPDATE token SET scopes = $1
WHERE email = $2 AND token_prefix = $3
AND (label IS NULL OR label <> 'guest_session')
RETURNING token_prefix",
req.scopes.as_deref(),
&authed.email,
@@ -3172,7 +3313,7 @@ async fn update_token_scopes(
let prefix = updated.ok_or_else(|| {
Error::NotFound(format!(
"token {token_prefix} not found or not owned by user"
"token {token_prefix} not found, not owned by user, or not rescopable"
))
})?;
@@ -3242,6 +3383,7 @@ async fn update_token_label(
WHERE email = $2 AND token_prefix = $3
AND (label IS NULL OR (
label <> 'session'
AND label <> 'guest_session'
AND lower(label) NOT LIKE 'ephemeral%'
AND label <> 'debugger-token'
AND label NOT LIKE 'mcp-oauth-%'
@@ -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},
@@ -151,6 +151,9 @@ 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(
"/default_scripts",
@@ -317,6 +320,17 @@ pub struct WorkspaceSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub public_app_execution_limit_per_minute: Option<i32>,
pub error_handler_fallback_to_instance_alerts: bool,
/// 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
@@ -339,6 +353,9 @@ pub struct WorkspacePublicSettings {
pub teams_team_guid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mute_critical_alerts: Option<bool>,
/// Not sensitive, and the app editor needs it to say whether the guest rung is
/// live -- an app can be set to `guest` while the workspace has guests off.
pub guest_access_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_ui: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1073,7 +1090,10 @@ async fn get_settings(
error_handler,
success_handler,
public_app_execution_limit_per_minute,
error_handler_fallback_to_instance_alerts
error_handler_fallback_to_instance_alerts,
guest_access_enabled,
guest_jwt_public_key,
guest_jwt_jwks_url
FROM
workspace_settings
WHERE
@@ -1112,6 +1132,7 @@ async fn get_public_settings(
teams_team_name,
teams_team_guid,
mute_critical_alerts,
guest_access_enabled,
deploy_ui,
large_file_storage,
datatable
@@ -1132,6 +1153,18 @@ async fn get_public_settings(
Ok(Json(settings))
}
/// The instance's standing against the guest allowance: counts only, no emails, so any
/// member may read it. Instance-wide, since a licence is per instance and one email is
/// one guest however many workspaces it opens; the settings card and the editor's
/// Guests rung show it so nobody discovers the cap from a visitor's complaint.
async fn get_guest_usage(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(_w_id): Path<String>,
) -> JsonResult<windmill_common::workspaces::GuestUsage> {
Ok(Json(windmill_common::workspaces::guest_usage(&db).await?))
}
#[derive(Deserialize)]
pub struct GitSyncDeployModeQuery {
/// The branch the caller would push.
@@ -3491,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())
@@ -3498,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)
}
};
}
@@ -3556,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,
@@ -4595,6 +4643,110 @@ async fn edit_default_app(
));
}
#[derive(Deserialize)]
struct EditGuestAccess {
guest_access_enabled: bool,
}
/// Turn guest sessions on or off for this workspace. Off by default, and off is
/// authoritative and immediate: the switch is re-read where a guest session is
/// minted (`guest_app_admits`) and at the auth door on every guest request, so an app
/// whose policy already says `guest` — pushed by git-sync, say — closes to guests on
/// the next request, sessions already issued included.
async fn edit_guest_access(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
guest_access_enabled,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_guest_access",
ActionKind::Update,
&w_id,
Some(&guest_access_enabled.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!(
"Guest access set to {guest_access_enabled} for workspace {w_id}"
))
}
#[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>,
@@ -11106,6 +11258,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,
});
};
@@ -11138,6 +11291,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) 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 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
)
@@ -187,6 +187,13 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
info!("Updating guest_activity table");
sqlx::query("UPDATE guest_activity SET workspace_id = $1 WHERE workspace_id = $2")
.bind(&rw.new_id)
.bind(&old_id)
.execute(&mut *tx)
.await?;
info!("Updating workspace_invite table");
sqlx::query!(
"UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2",
@@ -1112,6 +1119,13 @@ pub(crate) async fn delete_workspace(
.execute(&mut *tx)
.await?;
// Unlike the rest of this list, this also moves an instance-wide figure: the guest
// allowance and the seats past it are counted over every workspace's rows.
sqlx::query("DELETE FROM guest_activity WHERE workspace_id = $1")
.bind(&w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM token WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
+271 -9
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.803.0
version: 1.804.0
title: Windmill API
contact:
@@ -861,6 +861,34 @@ paths:
items:
$ref: "#/components/schemas/ExternalJwtToken"
/users/guests:
get:
summary: list the distinct guests of the trailing window (superadmin only)
description: >-
The set the guest allowance is counted on: every distinct email that held a
guest session in the last `window_days`, with the workspaces it opened and the
days it was first and last seen, most recently seen first. `usage` is the
instance's standing against the allowance and the meter.
operationId: listGuests
tags:
- user
parameters:
- name: page
in: query
schema:
type: integer
- name: per_page
in: query
schema:
type: integer
responses:
"200":
description: the guests of the window and the allowance they count against
content:
application/json:
schema:
$ref: "#/components/schemas/GuestList"
/users/onboarding:
post:
summary: Submit user onboarding data
@@ -3667,8 +3695,12 @@ paths:
$ref: "#/components/schemas/WorkspaceDeployUISettings"
mute_critical_alerts:
type: boolean
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.
required:
- workspace_id
- guest_access_enabled
/w/{workspace}/workspaces/get_settings:
get:
@@ -3750,6 +3782,15 @@ paths:
error_handler_fallback_to_instance_alerts:
type: boolean
description: Report failed jobs to the instance critical alert channels when no workspace error handler is set.
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:
@@ -5739,6 +5780,97 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_access:
post:
summary: enable or disable guest sessions for this workspace
description: >-
Guests are people the identity provider authenticates who have no Windmill
account; the `guest` app execution mode admits them. Off by default. Re-read
where a guest session is minted and at the auth door on every guest request, so
turning it off takes effect immediately, for sessions already issued and for
apps whose policy already says `guest`.
operationId: editGuestAccess
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Whether guest sessions are admitted
required: true
content:
application/json:
schema:
type: object
properties:
guest_access_enabled:
type: boolean
required:
- guest_access_enabled
responses:
"200":
description: status
content:
text/plain:
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
description: >-
Instance-wide, since a licence is per instance and one email is one guest however
many workspaces it opens. Read by workspace admins and app publishers to see how
close the cap (Community and Pro) or the meter (Enterprise) is.
operationId: getGuestUsage
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: guest usage
content:
application/json:
schema:
$ref: "#/components/schemas/GuestUsage"
/w/{workspace}/workspaces/default_scripts:
post:
summary: edit default scripts for workspace
@@ -8806,6 +8938,27 @@ paths:
required:
- app
/apps_u/guest_entry_by_custom_path/{custom_path}:
get:
summary: whether the app behind a custom path admits guests
description: >-
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
the app's execution mode is `guest` AND its workspace has
`guest_access_enabled` AND the instance has not set `guest_access_disabled`.
Returns the workspace too, since a custom URL may not carry it.
operationId: getGuestEntryByCustomPath
tags:
- app
parameters:
- $ref: "#/components/parameters/CustomPath"
responses:
"200":
description: the app is open to guests
content:
application/json:
schema:
$ref: "#/components/schemas/GuestEntry"
/apps_u/public_app_by_custom_path/{custom_path}:
get:
summary: get public app by custom path
@@ -12966,6 +13119,30 @@ paths:
schema:
type: string
/w/{workspace}/apps_u/guest_entry/{path}:
get:
summary: whether the app behind a share secret admits guests
description: >-
Unauthenticated: what a signed-out visitor reads to learn that signing in
would let them in. 404 unless the app's execution mode is `guest` AND the
workspace has `guest_access_enabled` AND the instance has not set the
`guest_access_disabled` global setting, so it says nothing about apps that
are not open to guests. Discloses only the app path, to a caller already
holding the share secret.
operationId: getGuestEntry
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: the app is open to guests
content:
application/json:
schema:
$ref: "#/components/schemas/GuestEntry"
/w/{workspace}/apps_u/public_app/{path}:
get:
summary: get public app by secret
@@ -28727,6 +28904,76 @@ components:
- is_operator
- last_used_at
GuestUsage:
type: object
description: >-
Guests are free up to `free_allowance` distinct emails over the trailing
`window_days`. Past that an Enterprise plan meters them (`metered`, four guests
to one seat: `billable_guests`, `guest_seats`); every other plan and build
admits no new email until the count drops. `instance_enabled` is the superadmin
switch (`guest_access_disabled` global setting) every workspace switch sits under.
properties:
instance_enabled:
type: boolean
guest_count:
type: integer
format: int64
window_days:
type: integer
free_allowance:
type: integer
format: int64
metered:
type: boolean
billable_guests:
type: integer
format: int64
guest_seats:
type: integer
format: int64
required:
- instance_enabled
- guest_count
- window_days
- free_allowance
- metered
- billable_guests
- guest_seats
GuestActivity:
type: object
properties:
email:
type: string
workspaces:
type: array
items:
type: string
first_seen:
type: string
format: date
last_seen:
type: string
format: date
required:
- email
- workspaces
- first_seen
- last_seen
GuestList:
type: object
properties:
usage:
$ref: "#/components/schemas/GuestUsage"
guests:
type: array
items:
$ref: "#/components/schemas/GuestActivity"
required:
- usage
- guests
NewToken:
type: object
properties:
@@ -33137,14 +33384,17 @@ components:
type: string
execution_mode:
type: string
enum: [viewer, publisher, anonymous]
enum: [viewer, publisher, guest, anonymous]
description: >-
Who the app's runnables execute as. Optional, and what omitting it
means depends on the operation: creating an app defaults it to
`publisher` (runs on behalf of the app's publisher and requires an
authenticated viewer), while updating one keeps the mode the app is
already deployed under. Either way `anonymous`, which makes the app
publicly executable, is never assumed
Who may open the app, and who its runnables execute as. Optional, and
what omitting it means depends on the operation: creating an app
defaults it to `publisher` (runs on behalf of the app's publisher and
requires an authenticated viewer), while updating one keeps the mode
the app is already deployed under. Neither `anonymous`, which makes
the app publicly executable, nor `guest`, which opens it to anyone the
identity provider authenticates, is ever assumed. A guest is only
admitted where the workspace also has `guest_access_enabled`, which is
checked when the session is minted and again on every guest request
on_behalf_of:
type: string
on_behalf_of_email:
@@ -33195,7 +33445,7 @@ components:
format: date-time
execution_mode:
type: string
enum: [viewer, publisher, anonymous]
enum: [viewer, publisher, guest, anonymous]
raw_app:
type: boolean
labels:
@@ -35009,6 +35259,17 @@ components:
description: Configuration of protection restrictions
items:
$ref: "#/components/schemas/ProtectionRuleKind"
GuestEntry:
type: object
description: What a signed-out visitor needs to start a guest sign-in.
properties:
workspace_id:
type: string
app_path:
type: string
required:
- workspace_id
- app_path
ProtectionRuleKind:
type: string
enum:
@@ -35017,6 +35278,7 @@ components:
- RestrictDeployToDeployers
- RestrictAnonymousAppDeployment
- RestrictPublicRunSharing
- RestrictGuestAppDeployment
RuleBypasserGroups:
type: array
description: Groups that can bypass this ruleset
+382 -73
View File
@@ -170,6 +170,7 @@ pub fn unauthed_service() -> Router {
)
.route("/load_csv_preview/{*path}", get(app_load_csv_preview))
.route("/public_app/{secret}", get(get_public_app_by_secret))
.route("/guest_entry/{secret}", get(get_guest_entry))
.route("/embed_token/{secret}", get(get_app_embed_token))
.route("/public_resource/{*path}", get(get_public_resource))
.route("/get_data/v/{*id}", get(get_raw_app_data))
@@ -288,6 +289,15 @@ pub type AllowUserResources = Vec<String>;
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
Anonymous,
/// Login required, workspace membership not: anyone the instance's identity
/// provider authenticates may open the app, and the runnables execute as the
/// publisher exactly as in [`ExecutionMode::Publisher`]. Such a viewer holds a
/// guest session: an identity with no account at all (no `password` row, no `usr`
/// row anywhere), which is what keeps it off every row-based seat counter; what a
/// guest costs instead is the allowance in `windmill_common::workspaces`. Honored
/// only where `workspace_settings.guest_access_enabled` is on, re-read at the auth
/// door on every guest request.
Guest,
/// Default for a policy that omits `execution_mode`. It MUST stay a mode
/// that requires an authenticated viewer: an omitted field must never be
/// able to publish an app anonymously (publicly executable).
@@ -296,6 +306,136 @@ pub enum ExecutionMode {
Viewer,
}
impl ExecutionMode {
/// The serialized form, matching this enum's `rename_all = "lowercase"`.
pub fn as_str(&self) -> &'static str {
match self {
ExecutionMode::Anonymous => "anonymous",
ExecutionMode::Guest => "guest",
ExecutionMode::Publisher => "publisher",
ExecutionMode::Viewer => "viewer",
}
}
}
/// The protection rule gating a *transition into* `mode`, if any. Anonymous and
/// guest each widen who may open an app past the workspace's own members, so each
/// carries its own rule; the two member-only modes are ungated.
fn deployment_rule_for_mode(mode: ExecutionMode) -> Option<ProtectionRuleKind> {
match mode {
ExecutionMode::Anonymous => Some(ProtectionRuleKind::RestrictAnonymousAppDeployment),
ExecutionMode::Guest => Some(ProtectionRuleKind::RestrictGuestAppDeployment),
ExecutionMode::Publisher | ExecutionMode::Viewer => None,
}
}
/// A guest session is scoped to its app by path, so an app whose path the scope
/// grammar cannot hold as one literal (`is_scope_literal_path`) can never admit a
/// guest; refuse the mode at deploy time rather than advertise an app nobody enters.
/// `path` is where the app ends up: on a rename, the destination.
fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> {
if matches!(mode, ExecutionMode::Guest) && !windmill_common::auth::is_scope_literal_path(path) {
return Err(Error::BadRequest(format!(
"app {path} cannot be set to Guests: a path with `:`, `,` or `*`, or a leading `/`, \
cannot be scoped"
)));
}
Ok(())
}
/// Gate a viewer on the app's `execution_mode`, as far as can be decided without an
/// ACL probe. `Ok(true)` means already authorized — anonymous admits anyone, guest
/// admits anyone signed in; `Ok(false)` means the caller is a member and still owes
/// the read-access check its caller performs.
///
/// A guest is authorized by its token's scope and never by an ACL probe: it holds no
/// `usr` row, so RLS finds nothing for it and every guest would read as having no
/// access. That scope is also what keeps a guest session to the one app it was minted
/// for, even though the mode itself admits anyone signed in. The workspace's guest
/// switch is not checked here: `AuthCache` enforces it for every guest request.
pub fn authorize_non_member_viewer(
mode: ExecutionMode,
app_path: &str,
opt_authed: &Option<ApiAuthed>,
) -> Result<bool> {
if matches!(mode, ExecutionMode::Anonymous) {
return Ok(true);
}
let Some(authed) = opt_authed.as_ref() else {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
));
};
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
if matches!(mode, ExecutionMode::Guest) {
if is_guest {
check_scopes(authed, || format!("apps:read:{}", app_path))?;
}
return Ok(true);
}
if is_guest {
return Err(Error::PermissionDenied(format!(
"app {app_path} is not open to guests"
)));
}
Ok(false)
}
/// Confines a guest to its app once the app's mode is known; a no-op for every other
/// caller. An anonymous app is open to anyone, so the guest stays the caller there,
/// as itself: the run and the reads that follow it (job results, S3 provenance) must
/// carry one identity. Anywhere else a mismatch is refused.
fn guest_caller_for_mode(
opt_authed: Option<ApiAuthed>,
mode: ExecutionMode,
app_path: &str,
) -> Result<Option<ApiAuthed>> {
let Some(authed) = opt_authed.as_ref() else {
return Ok(None);
};
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
|| matches!(mode, ExecutionMode::Anonymous)
{
return Ok(opt_authed);
}
check_scopes(authed, || format!("apps:run:{app_path}"))
.or_else(|_| check_scopes(authed, || format!("apps:read:{app_path}")))?;
Ok(opt_authed)
}
/// [`authorize_non_member_viewer`] plus the member read-access probe, for the
/// entry points that address an app by id.
async fn authorize_app_viewer(
mode: ExecutionMode,
app_path: &str,
app_id: i64,
w_id: &str,
user_db: &UserDB,
opt_authed: &Option<ApiAuthed>,
) -> Result<()> {
if authorize_non_member_viewer(mode, app_path, opt_authed)? {
return Ok(());
}
let authed = opt_authed
.as_ref()
.ok_or_else(|| Error::internal_err("authorize_app_viewer: unauthenticated".to_string()))?;
let mut tx = user_db.clone().begin(authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
app_id,
w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
Ok(())
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct PolicyTriggerableInputs {
static_inputs: StaticFields,
@@ -1218,29 +1358,15 @@ async fn get_public_app_by_secret(
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
if !matches!(policy.execution_mode(), ExecutionMode::Anonymous) {
if opt_authed.is_none() {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
));
} else {
let authed = opt_authed.unwrap();
let mut tx = user_db.begin(&authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
id,
&w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
}
}
authorize_app_viewer(
policy.execution_mode(),
&app.path,
id,
&w_id,
&user_db,
&opt_authed,
)
.await?;
// Compute bundle_secret for raw apps
if app.raw_app {
@@ -1356,9 +1482,18 @@ async fn mint_raw_app_sdk_token(
ensure_scopes_within_caller(authed, Some(scopes))?;
let mut scopes = scopes.to_vec();
scopes.push(windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string());
let expiration = chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
let requested_exp =
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
let (label, expiration) =
match guest_derived_token_constraints(db, authed, requested_exp).await? {
Some((label, exp)) => {
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
(label, exp)
}
None => (format!("sdk_app:{app_path}"), requested_exp),
};
let token_config = NewToken::new(
Some(format!("sdk_app:{app_path}")),
Some(label),
Some(expiration),
None,
Some(scopes),
@@ -1373,6 +1508,43 @@ async fn mint_raw_app_sdk_token(
Ok((token, expiration))
}
/// Label and expiry a token minted *by* a guest session must carry, or `None` for a
/// non-guest minter. The label is what lets it resolve; the caller pushes the `guest`
/// sentinel so every guest control still applies; the expiry is capped at the parent's,
/// since that expiry is a guest's only revocation short of logging out.
async fn guest_derived_token_constraints(
db: &DB,
authed: &ApiAuthed,
requested: chrono::DateTime<chrono::Utc>,
) -> Result<Option<(String, chrono::DateTime<chrono::Utc>)>> {
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
return Ok(None);
}
// 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(),
requested.min(parent_exp),
)))
}
/// Shared tail of the three embed-token endpoints: which credential the viewer
/// gets. Sandboxed low-code gets the embed token; a sandboxed raw app declaring
/// `frontend_sdk_scopes` gets the SDK token once `sdk_consent` is set — the
@@ -1400,7 +1572,22 @@ pub async fn build_embed_token_response(
&& opt_authed.is_some()
&& !policy.frontend_sdk_scopes.is_empty()
{
Some(policy.frontend_sdk_scopes.clone())
// An SDK token runs as the viewer, and a guest's session is the ceiling on what
// it may delegate — the mint enforces that. Advertise only what a guest can
// actually be granted, so the consent prompt never promises a scope the mint
// would then refuse.
let declared = policy.frontend_sdk_scopes.clone();
let offered = match opt_authed {
Some(a) if windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()) => {
let held = a.scopes.as_deref().unwrap_or_default();
declared
.into_iter()
.filter(|sc| held.iter().any(|h| h == sc))
.collect::<Vec<_>>()
}
_ => declared,
};
(!offered.is_empty()).then_some(offered)
} else {
None
};
@@ -1556,9 +1743,13 @@ pub async fn mint_app_embed_token(
"App embed tokens cannot mint or renew embed tokens".to_string(),
));
}
let expiration =
let requested_exp =
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
let mut scopes: Vec<String> = APP_EMBED_SCOPES
.iter()
.filter(|s| **s != windmill_api_auth::scopes::APP_EMBED_SENTINEL)
.map(|s| s.to_string())
.collect();
// Path-scoped read so the app can fetch its OWN definition (apps/get/p,
// which the in-workspace sandboxed viewer uses) — but no other app's. The
// public viewer fetches via apps_u/public_app and doesn't rely on this.
@@ -1569,10 +1760,22 @@ pub async fn mint_app_embed_token(
scopes.push(format!("apps:run:{app_path}"));
// A scope-restricted caller token must not bootstrap a broader-scoped
// embed token (`create_token_internal` deliberately does not check this
// itself). No-op for unscoped sessions — the normal embed flow.
// itself). Checked on the real scopes only: a sentinel is a one-part string
// that `ScopeDefinition::from_scope_string` rejects, so leaving it in the
// requested set makes this fail outright for any scoped caller — which a
// guest session is. `mint_raw_app_sdk_token` has the same shape.
ensure_scopes_within_caller(authed, Some(&scopes))?;
scopes.push(windmill_api_auth::scopes::APP_EMBED_SENTINEL.to_string());
let (label, expiration) =
match guest_derived_token_constraints(db, authed, requested_exp).await? {
Some((label, exp)) => {
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
(label, exp)
}
None => (format!("embed_app:{app_path}"), requested_exp),
};
let token_config = NewToken::new(
Some(format!("embed_app:{app_path}")),
Some(label),
Some(expiration),
None,
Some(scopes),
@@ -1601,6 +1804,40 @@ pub async fn mint_app_embed_token(
})
}
#[derive(Serialize)]
pub struct GuestEntry {
/// The workspace and app path to name when starting a guest sign-in. The
/// workspace is redundant on the secret route and load-bearing on the custom-path
/// one, which may not carry it in its URL.
pub workspace_id: String,
pub app_path: String,
}
/// Whether the app behind this share secret admits guests, and under what path.
///
/// Unauthenticated on purpose: it is what a signed-out visitor reads to learn that
/// signing in would get them in. It discloses only the app's path, to a caller who
/// already holds the share secret — the secret is the capability here. A 404 when the
/// app is not open to guests, so it says nothing about apps that are not.
async fn get_guest_entry(
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
) -> JsonResult<GuestEntry> {
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
let app = sqlx::query!(
"SELECT path FROM app WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
let app = not_found_if_none(app, "App", id.to_string())?;
if !windmill_common::workspaces::guest_app_admits(&db, &w_id, &app.path).await? {
return Err(Error::NotFound("App is not open to guests".to_string()));
}
Ok(Json(GuestEntry { workspace_id: w_id, app_path: app.path }))
}
/// Issue an embed token for a public app addressed by its (secret) share id.
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
/// reachable without auth, otherwise the caller must be logged in and have read
@@ -1646,29 +1883,18 @@ async fn get_app_embed_token(
let authed_for_token = if policy.anonymous_execution {
// Anonymous app: still mint a scoped token if the viewer happens to be
// logged in (so the app sees their identity), otherwise stay anonymous.
opt_authed
// logged in (so the app sees their identity), otherwise stay anonymous. A
// guest's session names another app and cannot contain this one's scopes,
// so it renders anonymously here rather than being refused.
opt_authed.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
} else {
let authed = opt_authed.ok_or_else(|| {
Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
)
})?;
let mut tx = user_db.begin(&authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
id,
&w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
Some(authed)
let mode = if policy.guest_execution {
ExecutionMode::Guest
} else {
ExecutionMode::Publisher
};
authorize_app_viewer(mode, &app.path, id, &w_id, &user_db, &opt_authed).await?;
opt_authed
};
let resp = build_embed_token_response(
@@ -1694,6 +1920,9 @@ async fn get_app_embed_token(
/// strictest access interpretation.
pub struct EmbedPolicyView {
pub anonymous_execution: bool,
/// Open to anyone the identity provider authenticates. Like
/// `anonymous_execution`, an unknown mode reads as `false` — the strict side.
pub guest_execution: bool,
pub sandbox: bool,
/// Raw apps: author-declared scopes for the frontend SDK token; empty when
/// the app doesn't use the frontend SDK (non-string entries are ignored).
@@ -1704,6 +1933,7 @@ pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
let v: serde_json::Value = serde_json::from_str(policy_str).map_err(to_anyhow)?;
Ok(EmbedPolicyView {
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
guest_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("guest"),
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
frontend_sdk_scopes: v
.get("frontend_sdk_scopes")
@@ -2287,10 +2517,11 @@ async fn create_app_internal<'a>(
// Pin the mode the app is created under, so the stored policy states one
// even when the caller did not.
app.policy.set_execution_mode(app.policy.execution_mode());
if matches!(app.policy.execution_mode(), ExecutionMode::Anonymous) {
refuse_unscopable_guest_app(&app.path, app.policy.execution_mode())?;
if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
w_id,
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
&rule,
&authed.username,
&authed.groups,
authed.is_admin,
@@ -3201,6 +3432,28 @@ async fn update_app_internal<'a>(
if npath != path {
require_owner_of_path(&authed, path)?;
// The destination is what a guest session would be scoped to. A rename
// that carries no policy keeps the deployed mode, read under the row
// lock so a policy update landing alongside cannot slip a guest app
// onto a path it cannot be scoped to.
let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) {
Some(mode) => mode,
None => sqlx::query_scalar::<_, Option<String>>(
"SELECT policy->>'execution_mode' FROM app
WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?
.flatten()
.and_then(|m| {
serde_json::from_value::<ExecutionMode>(serde_json::Value::String(m)).ok()
})
.unwrap_or_default(),
};
refuse_unscopable_guest_app(npath, mode)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
npath,
@@ -3308,21 +3561,26 @@ async fn update_app_internal<'a>(
.unwrap_or_default(),
);
}
if matches!(npolicy.execution_mode(), ExecutionMode::Anonymous) && !authed.is_admin {
// Restricted users may keep deploying an app that is already
// public, but flipping an app to anonymous (public) access is
// gated by the RestrictAnonymousAppDeployment protection rule.
// An unreadable deployed policy reads as not-anonymous, the
// strict direction.
let already_anonymous = deployed
refuse_unscopable_guest_app(
ns.path.as_deref().unwrap_or(path),
npolicy.execution_mode(),
)?;
if let Some(rule) =
deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin)
{
// Restricted users may keep deploying an app that is already open
// to this audience, but widening one is gated by the matching
// protection rule. An unreadable deployed policy reads as not
// already-widened, the strict direction.
let already_in_mode = deployed
.as_ref()
.and_then(|p| p.get("execution_mode"))
.and_then(|m| m.as_str())
== Some("anonymous");
if !already_anonymous {
== Some(npolicy.execution_mode().as_str());
if !already_in_mode {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
w_id,
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
&rule,
&authed.username,
&authed.groups,
authed.is_admin,
@@ -3561,6 +3819,21 @@ async fn get_on_behalf_details_from_policy_and_authed(
policy: &Policy,
opt_authed: &Option<ApiAuthed>,
) -> Result<(String, String, String)> {
// A guest acts only through an app open to guests — or to everyone. A members-only
// mode means the policy changed after the session was issued. Decided here, in the
// one resolver every on-behalf path (runs, S3 reads, uploads) goes through.
if opt_authed
.as_ref()
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
&& !matches!(
policy.execution_mode(),
ExecutionMode::Guest | ExecutionMode::Anonymous
)
{
return Err(Error::PermissionDenied(
"this app is not open to guests".to_string(),
));
}
let (username, permissioned_as, email) = match policy.execution_mode() {
ExecutionMode::Anonymous => {
let username = opt_authed
@@ -3570,7 +3843,9 @@ async fn get_on_behalf_details_from_policy_and_authed(
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Publisher => {
// Guest runs as the publisher exactly as Publisher does; the two differ only
// in who is let through the door, which is settled before we get here.
ExecutionMode::Publisher | ExecutionMode::Guest => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
@@ -3655,8 +3930,12 @@ async fn execute_component(
// Authorize before touching the payload: the route layer is resource-blind, so a
// path-scoped caller (app embed token, or a picker-minted `apps:run|write:<path>`)
// is confined to its own app only here. No-op for unscoped callers; anonymous ones
// are policy-gated below.
if let Some(authed) = opt_authed.as_ref() {
// are policy-gated below, and a guest's confinement waits for the app's mode
// (`guest_caller_for_mode`).
if let Some(authed) = opt_authed
.as_ref()
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
{
check_scopes(authed, || format!("apps:run:{}", path))?;
}
// Only honor temp_script_refs for the inline-script preview path:
@@ -3873,8 +4152,16 @@ async fn execute_component(
}
};
// Check rate limit for anonymous (public) executions
if matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none() {
// Rate limit for executions by callers the workspace does not know: anonymous
// viewers, and guests — on an instance whose provider accepts any consumer
// account, "anyone the IdP authenticates" is close to the anonymous population,
// and each run costs a job as the publisher.
let is_guest_caller = opt_authed
.as_ref()
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()));
if (matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none())
|| is_guest_caller
{
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
if limit > 0 {
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
@@ -3882,6 +4169,8 @@ async fn execute_component(
}
}
let opt_authed = guest_caller_for_mode(opt_authed, policy.execution_mode(), path)?;
// Execution is publisher and an user is authenticated: check if the user is authorized to
// execute the app.
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref())
@@ -4217,8 +4506,11 @@ async fn upload_s3_file_from_app(
request: axum::extract::Request,
) -> JsonResult<AppUploadFileResponse> {
// Same path confinement as `execute_component`: without it a token scoped to app A
// could drive app B's upload policy.
if let Some(authed) = opt_authed.as_ref() {
// could drive app B's upload policy. A guest's waits for the app's mode, below.
if let Some(authed) = opt_authed
.as_ref()
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
{
check_scopes(authed, || format!("apps:run:{}", path.to_path()))?;
}
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
@@ -4271,6 +4563,14 @@ async fn upload_s3_file_from_app(
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
.transpose()?
};
let opt_authed = guest_caller_for_mode(
opt_authed,
policy
.as_ref()
.map(Policy::execution_mode)
.unwrap_or_default(),
path.to_path(),
)?;
let user_db = UserDB::new(db.clone());
@@ -4428,8 +4728,12 @@ async fn upload_s3_file_from_app(
}
} else {
// backward compatibility (no policy)
// if no policy but logged in, use the user's auth to get the s3 resource
if let Some(authed) = opt_authed {
// if no policy but logged in, use the user's auth to get the s3 resource. A guest
// has no standing of its own to upload with, so without a policy it is refused
// exactly as an anonymous caller is.
if let Some(authed) = opt_authed
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
{
let file_key = query
.file_key
.unwrap_or_else(|| get_random_file_name(query.file_extension));
@@ -4687,6 +4991,7 @@ async fn get_on_behalf_authed_from_app(
})
};
let opt_authed = guest_caller_for_mode(opt_authed.clone(), policy.execution_mode(), path)?;
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
@@ -4843,6 +5148,10 @@ fn check_app_s3_read_scope(opt_authed: &Option<ApiAuthed>, path: &str) -> Result
let Some(authed) = opt_authed.as_ref() else {
return Ok(());
};
// A guest's confinement waits for the app's mode (`get_on_behalf_authed_from_app`).
if windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
return Ok(());
}
check_scopes(authed, || format!("apps:run:{}", path))
.or_else(|_| check_scopes(authed, || format!("apps:read:{}", path)))
}
+6 -1
View File
@@ -1594,7 +1594,11 @@ pub(crate) async fn require_job_read_access(
// this token, and letting it reach any job merely visible to the viewer would
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
// A guest stops here too: it has no membership behind it, so a share token whose
// audience is the workspace's members must not read for it either.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref())
|| windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
{
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
@@ -11712,6 +11716,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,
}
}
+53
View File
@@ -55,6 +55,7 @@ pub fn global_service() -> Router {
.route("/rename/{user}", post(rename_user))
.route("/onboarding", post(submit_onboarding_data))
.route("/ext_jwt_tokens", get(list_ext_jwt_tokens))
.route("/guests", get(list_guests))
.route(
"/offboard_preview/{user}",
get(crate::offboarding::global_offboard_preview),
@@ -141,6 +142,58 @@ async fn list_ext_jwt_tokens(
Ok(Json(rows))
}
#[derive(serde::Serialize, sqlx::FromRow)]
pub struct GuestActivity {
pub email: String,
pub workspaces: Vec<String>,
pub first_seen: chrono::NaiveDate,
pub last_seen: chrono::NaiveDate,
}
#[derive(serde::Serialize)]
pub struct GuestList {
pub usage: windmill_common::workspaces::GuestUsage,
pub guests: Vec<GuestActivity>,
}
#[derive(serde::Deserialize)]
struct ListGuestsQuery {
page: Option<usize>,
per_page: Option<usize>,
}
/// The distinct guests of the trailing window, the set the allowance is counted on,
/// most recently seen first.
async fn list_guests(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<ListGuestsQuery>,
) -> Result<Json<GuestList>> {
require_super_admin(&db, &authed).await?;
let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination {
page: query.page,
per_page: query.per_page,
});
let usage = windmill_common::workspaces::guest_usage(&db).await?;
let guests = sqlx::query_as::<_, GuestActivity>(
"SELECT email, array_agg(DISTINCT workspace_id) AS workspaces,
MIN(day) AS first_seen, MAX(day) AS last_seen
FROM guest_activity
WHERE day > CURRENT_DATE - $3
GROUP BY email
ORDER BY MAX(day) DESC, email
LIMIT $1 OFFSET $2",
)
.bind(per_page as i64)
.bind(offset as i64)
.bind(windmill_common::workspaces::GUEST_WINDOW_DAYS)
.fetch_all(&db)
.await?;
Ok(Json(GuestList { usage, guests }))
}
async fn set_password(
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
+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
+33 -1
View File
@@ -19,7 +19,7 @@ use crate::{
};
/// Whether `label` denotes a user-created token rather than a system token
/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
/// notifications and username overrides all key off them — so they must not be
/// user-editable. `None` (no label) is treated as a user token.
@@ -36,6 +36,7 @@ pub fn is_user_token(label: Option<&str>) -> bool {
// frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and
// the SQL `lower(label) NOT LIKE 'ephemeral%'` guard.
l != "session"
&& l != GUEST_SESSION_LABEL
&& !l.to_lowercase().starts_with("ephemeral")
&& l != "debugger-token"
&& !l.starts_with("mcp-oauth-")
@@ -56,9 +57,40 @@ pub fn is_server_minted_label(label: &str) -> bool {
|| label.starts_with("ephemeral-script-end-user-")
|| label == "ephemeral-script"
|| label == "session"
|| label == GUEST_SESSION_LABEL
|| label.starts_with("mcp-oauth-")
}
/// Label on a guest session (the `guest` app execution mode). This is the *grant*:
/// `AuthCache` will resolve a token carrying it into an identity with no account behind
/// it, which nothing else can do. It must therefore stay unforgeable, which is what
/// listing it in [`is_server_minted_label`] buys — `/users/tokens/create` refuses it.
///
/// Do not move this test onto the token's scopes. Scopes on a user-minted token are
/// caller-supplied and only ever *narrow* (`app_embed`, `raw_app_sdk`), so a scope
/// that granted non-member access would be free for anyone to declare.
pub const GUEST_SESSION_LABEL: &str = "guest_session";
/// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`].
///
/// Reserved in [`is_user_token`] as well as [`is_server_minted_label`]: the former
/// gates relabelling, and a user token that could be relabelled *into* this
/// namespace would become a guest session with no workspace pin — one that
/// authenticates everywhere.
pub fn is_guest_session_label(label: Option<&str>) -> bool {
label == Some(GUEST_SESSION_LABEL)
}
/// Whether `path` can be spliced into a scope as one literal resource. The scope
/// grammar reserves three characters: `:` separates the parts, `,` separates
/// resources, `*` is a wildcard. App paths are otherwise free-form (spaces, `@`). A
/// leading `/` is refused too: routes strip it, so the scope would never match.
pub fn is_scope_literal_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.chars().any(|c| matches!(c, ':' | ',' | '*'))
}
/// Whether `label` is the one minted for a browser session at login. [`is_server_minted_label`]
/// stops a member minting it directly, but `/users/refresh_token` hands one to any authenticated
/// caller, so this attributes a request to the UI without proving it: never gate authority on it.
@@ -65,6 +65,10 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics";
pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics";
pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
/// Superadmin switch over guest sessions for the whole instance, above the per-workspace
/// one. Read from the table, uncached, by the same gates that read the workspace switch;
/// the superadmin Guests list writes it through `/settings/global/{key}` by this name.
pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled";
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
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
+201
View File
@@ -71,6 +71,7 @@ bitflags::bitflags! {
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
const RESTRICT_GUEST_APP_DEPLOYMENT = 1 << 5;
}
}
@@ -83,6 +84,7 @@ pub enum ProtectionRuleKind {
RestrictDeployToDeployers,
RestrictAnonymousAppDeployment,
RestrictPublicRunSharing,
RestrictGuestAppDeployment,
}
impl ProtectionRuleKind {
@@ -103,6 +105,9 @@ impl ProtectionRuleKind {
ProtectionRuleKind::RestrictPublicRunSharing => {
ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING
}
ProtectionRuleKind::RestrictGuestAppDeployment => {
ProtectionRules::RESTRICT_GUEST_APP_DEPLOYMENT
}
}
}
@@ -121,6 +126,9 @@ impl ProtectionRuleKind {
ProtectionRuleKind::RestrictPublicRunSharing => {
"Sharing a run publicly (readable without login) is restricted in this workspace"
}
ProtectionRuleKind::RestrictGuestAppDeployment => {
"Opening an app to guests (anyone who can sign in) is restricted in this workspace"
}
}
}
}
@@ -767,6 +775,185 @@ pub struct BillableSeats {
pub seats: i64,
}
/// Guests are free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the trailing
/// `GUEST_WINDOW_DAYS`. Past that, an Enterprise plan meters them, `GUESTS_PER_SEAT`
/// guests to one seat, while every other plan and build stops admitting new emails.
pub const GUEST_WINDOW_DAYS: i32 = 30;
pub const FREE_GUESTS_PER_WINDOW: i64 = 100;
pub const GUESTS_PER_SEAT: i64 = 4;
/// Whether guests past the allowance are metered (Enterprise plan) rather than refused.
/// A build without `enterprise` has no plan and is capped, like a Pro key.
pub async fn guests_are_metered() -> bool {
#[cfg(feature = "enterprise")]
{
matches!(
crate::ee_oss::get_license_plan().await,
crate::ee_oss::LicensePlan::Enterprise
)
}
#[cfg(not(feature = "enterprise"))]
{
false
}
}
/// Seats the guests past the free allowance consume: `ceil(billable / GUESTS_PER_SEAT)`.
pub fn guest_seats(distinct_guests: i64) -> i64 {
let billable = (distinct_guests - FREE_GUESTS_PER_WINDOW).max(0);
(billable + GUESTS_PER_SEAT - 1) / GUESTS_PER_SEAT
}
/// Distinct guest emails over the trailing window, today included.
pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
executor: E,
) -> Result<i64> {
sqlx::query_scalar(
"SELECT COUNT(DISTINCT email) FROM guest_activity WHERE day > CURRENT_DATE - $1",
)
.bind(GUEST_WINDOW_DAYS)
.fetch_one(executor)
.await
.map_err(|e| Error::internal_err(format!("counting guests: {e:#}")))
}
/// The instance's standing against the guest allowance, as every surface reports it.
#[derive(Clone, Debug, Serialize)]
pub struct GuestUsage {
/// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace
/// switch sits under.
pub instance_enabled: bool,
/// Distinct guest emails over the trailing `window_days`.
pub guest_count: i64,
pub window_days: i32,
pub free_allowance: i64,
/// Enterprise plan: guests past the allowance take `guest_seats`. Otherwise no new
/// email is admitted past it.
pub metered: bool,
pub billable_guests: i64,
pub guest_seats: i64,
}
/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. The
/// setting is read as text before the cast so `true` and `"true"` both count.
fn instance_admits_guests_sql() -> String {
format!(
"NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \
WHERE name = '{}'), false)",
crate::global_settings::GUEST_ACCESS_DISABLED_SETTING
)
}
pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
let instance_admits = instance_admits_guests_sql();
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_admits}"))
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?;
let guest_count = guest_count_in_window(db).await?;
let metered = guests_are_metered().await;
let billable_guests = if metered {
(guest_count - FREE_GUESTS_PER_WINDOW).max(0)
} else {
0
};
Ok(GuestUsage {
instance_enabled,
guest_count,
window_days: GUEST_WINDOW_DAYS,
free_allowance: FREE_GUESTS_PER_WINDOW,
metered,
billable_guests,
guest_seats: if metered { guest_seats(guest_count) } else { 0 },
})
}
/// Whether `email` may be admitted as a guest right now. Checked once, where a session
/// is minted: a returning guest (already in the window) is always let back in, so the
/// cap only ever refuses a stranger, and a metered instance refuses nobody.
///
/// Must run inside the transaction that then records the guest in `guest_activity`:
/// it takes a transaction-scoped lock so concurrent strangers count each other, and the
/// lock is what keeps the cap exact rather than approximate.
pub async fn guest_admission(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
if guests_are_metered().await {
return Ok(());
}
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('guest_allowance'))")
.execute(&mut *conn)
.await
.map_err(|e| Error::internal_err(format!("locking the guest allowance: {e:#}")))?;
let (in_window, count): (bool, i64) = sqlx::query_as(
"SELECT
EXISTS(SELECT 1 FROM guest_activity WHERE email = $1 AND day > CURRENT_DATE - $2),
(SELECT COUNT(DISTINCT email) FROM guest_activity WHERE day > CURRENT_DATE - $2)",
)
.bind(email)
.bind(GUEST_WINDOW_DAYS)
.fetch_one(&mut *conn)
.await
.map_err(|e| Error::internal_err(format!("checking the guest allowance: {e:#}")))?;
if in_window || count < FREE_GUESTS_PER_WINDOW {
return Ok(());
}
Err(Error::PermissionDenied(format!(
"This instance has reached its limit of {FREE_GUESTS_PER_WINDOW} guests over \
{GUEST_WINDOW_DAYS} days. Guest sign-in beyond that needs an Enterprise license."
)))
}
/// Whether a guest session for `email` in `w_id` still stands: the instance and the
/// workspace admit guests, and the email still has no account. Read at the auth door on
/// every guest request, so turning either switch off, or an account provisioned after
/// the mint (or racing it), ends the session on its next request.
pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Result<bool> {
let instance_admits = instance_admits_guests_sql();
let stands: Option<bool> = sqlx::query_scalar(&format!(
"SELECT guest_access_enabled
AND {instance_admits}
AND NOT EXISTS(SELECT 1 FROM password WHERE email = $2)
AND NOT EXISTS(SELECT 1 FROM usr WHERE email = $2)
FROM workspace_settings WHERE workspace_id = $1"
))
.bind(w_id)
.bind(email)
.fetch_optional(db)
.await
.map_err(|e| Error::internal_err(format!("checking the guest session of {email}: {e:#}")))?;
Ok(stands.unwrap_or(false))
}
/// Every switch at once: the instance's, the workspace's, and `app_path` being in
/// `guest` execution mode. The single answer to "may a guest session be minted for this
/// app", used by the mint itself and by the sign-in branch that decides whether to call
/// it. A missing app or a policy with no stated mode reads as "no". The allowance is
/// `guest_admission`.
pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
executor: E,
w_id: &str,
app_path: &str,
) -> Result<bool> {
// The mint refuses a path it cannot scope, so discovery must not advertise one.
if !crate::auth::is_scope_literal_path(app_path) {
return Ok(false);
}
let instance_admits = instance_admits_guests_sql();
let admits: Option<bool> = sqlx::query_scalar(&format!(
"SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false)
AND {instance_admits}
FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id
WHERE app.workspace_id = $1 AND app.path = $2"
))
.bind(w_id)
.bind(app_path)
.fetch_optional(executor)
.await
.map_err(|e| {
Error::internal_err(format!("checking guest access to {w_id}/{app_path}: {e:#}"))
})?;
Ok(admits.unwrap_or(false))
}
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
///
@@ -2804,3 +2991,17 @@ pub async fn dbt_warehouse_resource(
.map(|t| t.to_string());
Ok((path, target))
}
#[cfg(test)]
mod guest_allowance_tests {
use super::*;
#[test]
fn guest_seats_round_up_past_the_allowance() {
assert_eq!(guest_seats(0), 0);
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW), 0);
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + 1), 1);
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + GUESTS_PER_SEAT), 1);
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + GUESTS_PER_SEAT + 1), 2);
}
}
+2
View File
@@ -88,6 +88,8 @@ pub struct OAuthConfig {
#[serde(default = "empty_string")]
pub token_url: String,
pub userinfo_url: Option<String>,
/// The registry JSON may also carry `scope_options`, a frontend-only pick
/// list for the connect dialog; it is deliberately not modelled here.
pub scopes: Option<Vec<String>>,
/// Default scopes for the client-credentials (2-legged) flow. These differ
/// from the authorization-code `scopes` for most providers (member/consent
+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"]
+19 -1
View File
@@ -1099,7 +1099,7 @@ pub async fn run_agent(
// For non-Anthropic providers, response_format is handled by the query builder
}
let user_wants_streaming = args.streaming.unwrap_or(false);
let user_wants_streaming = streaming_requested(args.streaming);
*has_stream = user_wants_streaming && is_text_output;
let mut final_events_str = String::new();
@@ -1701,6 +1701,17 @@ pub async fn run_agent(
}))
}
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
///
/// The chat surfaces decide whether to open a stream from their own reading of the same config,
/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the
/// connection times out. So this default is half of a contract, not a local preference.
fn streaming_requested(streaming: Option<bool>) -> bool {
streaming.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1713,6 +1724,13 @@ mod tests {
}
}
#[test]
fn an_unwritten_streaming_field_streams() {
assert!(streaming_requested(None));
assert!(streaming_requested(Some(true)));
assert!(!streaming_requested(Some(false)));
}
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
/// trip per run and silently leaves that step with no prompt caching at all.
#[test]
+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 {}",
@@ -1223,6 +1223,7 @@ mount {{
result,
job,
conn,
canceled_by,
modules,
new_args.as_ref(),
))
+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({
+27 -11
View File
@@ -24,6 +24,7 @@ import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
export interface AppFile {
guests?: boolean;
value: any;
public?: boolean;
summary: string;
@@ -110,6 +111,28 @@ export function replaceInlineScripts(
export function isExecutionModeAnonymous(app: any) {
return app?.["policy"]?.["execution_mode"] == "anonymous";
}
export function isExecutionModeGuest(app: any) {
return app?.["policy"]?.["execution_mode"] == "guest";
}
export type AppExecutionMode = "anonymous" | "guest" | "publisher";
/** The access mode is the one policy field a tracked app keeps, as `public` (anonymous)
* or `guests` (guest); the rest of the policy is regenerated on push. */
export function markAccessFromPolicy(app: any) {
if (isExecutionModeAnonymous(app)) {
app.public = true;
} else if (isExecutionModeGuest(app)) {
app.guests = true;
}
}
export function executionModeFromAppFile(app: any): AppExecutionMode {
if (app?.["public"] ?? isExecutionModeAnonymous(app)) {
return "anonymous";
}
if (app?.["guests"] ?? isExecutionModeGuest(app)) {
return "guest";
}
return "publisher";
}
export async function pushApp(
workspace: string,
remotePath: string,
@@ -140,9 +163,7 @@ export async function pushApp(
remoteOnBehalfOfEmail = app.policy.on_behalf_of_email;
}
if (isExecutionModeAnonymous(app)) {
app.public = true;
}
markAccessFromPolicy(app);
// console.log(app);
if (app) {
app.policy = undefined;
@@ -155,12 +176,7 @@ export async function pushApp(
const localApp = (await yamlParseFile(path)) as AppFile;
replaceInlineScripts(localApp.value, localPath, true);
await generatingPolicy(
localApp,
remotePath,
localApp?.["public"] ??
localApp?.["policy"]?.["execution_mode"] == "anonymous"
);
await generatingPolicy(localApp, remotePath, executionModeFromAppFile(localApp));
const preserveFields: { preserve_on_behalf_of?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
@@ -230,12 +246,12 @@ export async function pushApp(
export async function generatingPolicy(
app: any,
path: string,
publicApp: boolean
executionMode: AppExecutionMode
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updatePolicy(app.value, undefined);
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
app.policy.execution_mode = executionMode;
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
+12 -7
View File
@@ -15,7 +15,13 @@ import { readdir } from "node:fs/promises";
import { GlobalOptions, isSuperset } from "../../types.ts";
import { deepEqual, readTextFile } from "../../utils/utils.ts";
import { replaceInlineScripts, repopulateFields } from "./app.ts";
import {
type AppExecutionMode,
executionModeFromAppFile,
markAccessFromPolicy,
replaceInlineScripts,
repopulateFields,
} from "./app.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER, RECORDINGS_FOLDER } from "./app_metadata.ts";
import { writeIfChanged } from "../../utils/utils.ts";
@@ -27,6 +33,7 @@ import {
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
export interface AppFile {
guests?: boolean;
runnables?: any;
custom_path?: string;
public?: boolean;
@@ -369,9 +376,7 @@ export async function pushRawApp(
} catch {
//ignore
}
if (app?.["policy"]?.["execution_mode"] == "anonymous") {
app.public = true;
}
markAccessFromPolicy(app);
// console.log(app);
if (app) {
app.policy = undefined;
@@ -422,7 +427,7 @@ export async function pushRawApp(
await generatingPolicy(
appForPolicy,
remotePath,
localApp?.["public"] ?? false,
executionModeFromAppFile(localApp),
);
const files = await collectAppFiles(localPath);
@@ -526,7 +531,7 @@ export async function pushRawApp(
export async function generatingPolicy(
app: any,
path: string,
publicApp: boolean,
executionMode: AppExecutionMode,
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
@@ -534,7 +539,7 @@ export async function generatingPolicy(
app.runnables,
app.policy,
);
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
app.policy.execution_mode = executionMode;
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
+3 -7
View File
@@ -142,7 +142,7 @@ import {
extractCurrentMapping,
} from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { isExecutionModeAnonymous } from "../app/app.ts";
import { markAccessFromPolicy } from "../app/app.ts";
import {
APP_BACKEND_FOLDER,
generateAppLocksInternal,
@@ -1393,9 +1393,7 @@ export function ZipFSElement(
};
}
if (isExecutionModeAnonymous(app)) {
app.public = true;
}
markAccessFromPolicy(app);
app.policy = undefined;
yield {
isDirectory: false,
@@ -1413,9 +1411,7 @@ export function ZipFSElement(
log.error(`Failed to parse app.yaml at path: ${p}`);
throw error;
}
if (rawApp?.["policy"]?.["execution_mode"] == "anonymous") {
rawApp.public = true;
}
markAccessFromPolicy(rawApp);
// console.log("rawApp", rawApp);
rawApp.policy = undefined;
// custom_path is derived from the file path, don't store it
+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";
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test";
import {
executionModeFromAppFile,
generatingPolicy,
markAccessFromPolicy,
} from "../src/commands/app/app.ts";
// The access mode is the one policy field a tracked app keeps; a pull then a push must
// deploy the mode that was pulled, guest included, not a default.
test("the access mode survives the app.yaml round trip", async () => {
const guest: any = { policy: { execution_mode: "guest" }, value: {} };
markAccessFromPolicy(guest);
guest.policy = undefined;
expect(guest.guests).toBe(true);
expect(guest.public).toBeUndefined();
expect(executionModeFromAppFile(guest)).toBe("guest");
await generatingPolicy(guest, "u/test/app", executionModeFromAppFile(guest));
expect(guest.policy.execution_mode).toBe("guest");
const anonymous: any = { policy: { execution_mode: "anonymous" }, value: {} };
markAccessFromPolicy(anonymous);
anonymous.policy = undefined;
expect(anonymous.public).toBe(true);
expect(executionModeFromAppFile(anonymous)).toBe("anonymous");
expect(executionModeFromAppFile({ policy: { execution_mode: "publisher" } })).toBe("publisher");
expect(executionModeFromAppFile({})).toBe("publisher");
});
+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));
}
+2
View File
@@ -35,6 +35,8 @@ export interface SchemaProperty {
}
min?: number
max?: number
/** Height a string field's text area opens at, in rows. */
minRows?: number
currency?: string
currencyLocale?: string
multiselect?: boolean
@@ -4,11 +4,7 @@
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
import type { AIProvider, ProviderConfig } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { get } from 'svelte/store'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ResourcePicker from './ResourcePicker.svelte'
import ToggleButtonMore from './common/toggleButton-v2/ToggleButtonMore.svelte'
import Toggle from './Toggle.svelte'
import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage'
import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte'
@@ -17,9 +13,20 @@
value: ProviderConfig | undefined
disabled?: boolean
actions?: Snippet
/** The workspace the surface operates on, which a session or fork editor sets to something
* other than the one being navigated. Resources and the models read off them are per
* workspace, so without it this offers what the wrong one holds. */
workspace?: string | undefined
}
let { value: _uncheckedValue = $bindable(), disabled = false, actions }: Props = $props()
let {
value: _uncheckedValue = $bindable(),
disabled = false,
actions,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '')
let value = $derived.by(() => {
if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined
@@ -30,7 +37,13 @@
let availableModels = $state<string[]>([])
let filterText = $state('')
let modelsCache = new Map<AIProvider, string[]>()
// Keyed by provider *and* path: two `customai` resources point at different base URLs, so they
// do not share a model list.
let modelsCache = new Map<string, string[]>()
// The resource picker offers every provider type at once and the pick is what names the kind.
// One string for the component's life: it is what the picker queries with.
const providerResourceTypes = Object.keys(AI_PROVIDERS).join(',')
if (!_uncheckedValue) {
_uncheckedValue = {
@@ -57,12 +70,6 @@
return r
})
// Provider options for the toggle button group
const providerOptions = Object.entries(AI_PROVIDERS).map(([key, details]) => ({
value: key as AIProvider,
label: details.label
}))
async function loadModels(signal?: AbortSignal) {
const provider = value?.kind
const resourceValue = value?.resource
@@ -73,20 +80,20 @@
}
loading = true
if (modelsCache.has(provider)) {
availableModels = modelsCache.get(provider) || []
const cacheKey = `${effectiveWorkspace}:${provider}:${resourcePath}`
if (modelsCache.has(cacheKey)) {
availableModels = modelsCache.get(cacheKey) || []
loading = false
return
}
try {
const workspace = get(workspaceStore) || ''
const models = await fetchAvailableModels(resourcePath, workspace, provider, signal)
const models = await fetchAvailableModels(resourcePath, effectiveWorkspace, provider, signal)
if (signal?.aborted) {
return
}
availableModels = models
modelsCache.set(provider, models)
modelsCache.set(cacheKey, models)
} catch (e) {
if (signal?.aborted) {
return
@@ -101,15 +108,24 @@
}
}
// Handle provider selection
function onProviderChange(selectedProvider: AIProvider) {
if (value) {
value.kind = selectedProvider
value.resource = ''
value.model = ''
// Reasoning effort is model-specific; reset it with the model.
value.reasoning_effort = undefined
/**
* The provider kind follows the resource that was picked. Driven by the pick rather than by an
* effect on the picker's `valueType`, which also resolves for the value the field was opened on
* and would rewrite a saved config just for being looked at.
*/
function onResourcePicked(_path: string | undefined, type: string | undefined) {
// An empty type is the placeholder the picker keeps for a saved path it could not find. It
// says nothing about the provider, so the kind stands.
if (!value || !type || !(type in AI_PROVIDERS)) {
return
}
if (value.kind === type) {
return
}
value.kind = type as AIProvider
// Models are per provider, and a reasoning token is per model.
value.model = ''
value.reasoning_effort = undefined
}
// Helper functions to handle $res: prefix like ObjectResourceInput does
@@ -165,97 +181,74 @@
})
</script>
<div class="w-full flex flex-col gap-1 border rounded-md p-4">
<!-- Provider Selection -->
<ToggleButtonGroup
selected={value?.kind}
onSelected={onProviderChange}
{disabled}
wrap
tabListClass="w-full"
>
{#snippet children({ item })}
{#each providerOptions.slice(0, 3) as option}
<ToggleButton value={option.value} label={option.label} {item} />
{/each}
<ToggleButtonMore
class="ml-auto"
btnText={providerOptions.findIndex((p) => p.value === value?.kind) >= 3 ? '' : 'More'}
togglableItems={providerOptions.slice(3)}
{item}
bind:selected={() => value?.kind, (v) => v && onProviderChange(v)}
/>
{/snippet}
</ToggleButtonGroup>
<!-- Resource Selection -->
<div class="flex flex-col rounded-md pt-2 gap-2">
<div class="flex flex-col gap-1">
<p class="text-xs font-normal text-primary">resource</p>
<ResourcePicker
bind:value={
() => resourceValueToPath(value?.resource),
(v) => {
if (value) {
value.resource = pathToResourceValue(v) ?? ''
}
<div class="w-full flex flex-col gap-3 border rounded-md p-4">
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Resource</span>
<!-- No auto-select: this picker spans every provider type, so a single candidate means "the
only AI resource in the workspace" rather than "the only one of the kind this agent uses".
Taking it would redefine the agent's provider and drop its model, on open and unasked. -->
<ResourcePicker
bind:value={
() => resourceValueToPath(value?.resource),
(v) => {
if (value) {
value.resource = pathToResourceValue(v) ?? ''
}
}
resourceType={value?.kind}
disabled={disabled || !value?.kind}
placeholder="Select resource"
selectFirst={true}
/>
</div>
}
resourceType={providerResourceTypes}
{disabled}
{workspace}
placeholder="Select an AI provider resource"
selectFirst={false}
onValueChange={onResourcePicked}
/>
</div>
<!-- Model Selection -->
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Model</span>
<Select
{items}
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
placeholder="Select model"
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
onCreateItem={(r) => {
availableModels.push(r)
if (value) value.model = r
}}
createText="Press enter to use custom model"
{loading}
clearable={false}
noItemsMsg={'No models available'}
bind:filterText
/>
</div>
{#if value?.model}
<div class="flex flex-col gap-1">
<p class="text-xs font-normal text-primary">model</p>
<Select
{items}
bind:value={() => value?.model, (v) => value && (value.model = v ?? '')}
placeholder="Select model"
disabled={disabled || !value?.kind || !resourceValueToPath(value?.resource)}
onCreateItem={(r) => {
availableModels.push(r)
if (value) value.model = r
}}
createText="Press enter to use custom model"
{loading}
clearable={false}
noItemsMsg={'No models available'}
bind:filterText
<span class="text-xs font-normal text-secondary">Reasoning effort</span>
<AIReasoningEffortPicker
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
providerConfig={value}
{disabled}
/>
</div>
{/if}
<!-- Reasoning Effort (shown once a model is selected) -->
{#if value?.model}
<div class="flex flex-col gap-1">
<p class="text-xs font-normal text-primary">reasoning effort</p>
<AIReasoningEffortPicker
bind:value={() => value?.reasoning_effort, (v) => value && (value.reasoning_effort = v)}
providerConfig={value}
{disabled}
/>
</div>
{/if}
<!-- Use as Default Checkbox -->
<div class="flex justify-end pt-1">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
<div class="flex justify-end">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
{@render actions?.()}

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