mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
Merge remote-tracking branch 'origin/main' into dbt-profiles-env-secrets
This commit is contained in:
@@ -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,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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.803.0"
|
||||
".": "1.804.0"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+26
@@ -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"
|
||||
}
|
||||
+10
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -15,16 +15,21 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "guest_workspace_count!",
|
||||
"name": "guest_jwt_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "guest_enabled_workspace_count!",
|
||||
"name": "guest_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "guest_enabled_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "workspace_count!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
@@ -37,8 +42,9 @@
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60"
|
||||
"hash": "06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -167,6 +167,16 @@
|
||||
"ordinal": 32,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"name": "guest_jwt_public_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "guest_jwt_jwks_url",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -207,8 +217,10 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2"
|
||||
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e2eee8de61337b7d093f38e3e393e3620111119abf8dda4bde5b835ff934e4f1"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
Generated
+197
-194
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.803.0"
|
||||
version = "1.804.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.803.0"
|
||||
version = "1.804.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -367,6 +367,7 @@ aws-config.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -599,6 +600,8 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
|
||||
const-str = "0.5"
|
||||
constant_time_eq = "0.3.1"
|
||||
rsa = "^0"
|
||||
spki = { version = "0.7", features = ["pem"] }
|
||||
pkcs1 = "0.7"
|
||||
aes-gcm = "0.10.3"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
|
||||
once_cell = "1.17.1"
|
||||
|
||||
@@ -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 @@
|
||||
fb1c5c109846d6c47aff70ab6cc631f4fd773678
|
||||
d33ea730c550cdbc7d050aeb6d40dcef3d134e07
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE guest_activity DROP COLUMN jwt_entry;
|
||||
ALTER TABLE workspace_settings
|
||||
DROP CONSTRAINT workspace_settings_guest_jwt_one_key,
|
||||
DROP COLUMN guest_jwt_public_key,
|
||||
DROP COLUMN guest_jwt_jwks_url;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- A second way in for a guest: a JWT minted by the embedding customer's own backend and
|
||||
-- verified against a key the workspace admin configured. One key shape per workspace,
|
||||
-- a PEM public key or a JWKS URL, never both: a token is verified against exactly one
|
||||
-- source, and two would make "which one refused it" undiagnosable.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN guest_jwt_public_key TEXT,
|
||||
ADD COLUMN guest_jwt_jwks_url TEXT,
|
||||
ADD CONSTRAINT workspace_settings_guest_jwt_one_key
|
||||
CHECK (guest_jwt_public_key IS NULL OR guest_jwt_jwks_url IS NULL);
|
||||
|
||||
-- Whether the guest came in on a JWT that day (as opposed to, or as well as, an
|
||||
-- identity-provider sign-in). The seat telemetry reports the two entries apart, since
|
||||
-- an app-only user routed through a guest JWT is one that `jwt_ext_` would have counted.
|
||||
ALTER TABLE guest_activity
|
||||
ADD COLUMN jwt_entry BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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
@@ -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>"]
|
||||
|
||||
|
||||
@@ -110,7 +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)
|
||||
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)
|
||||
@@ -223,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), guest_access_enabled(bool)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -614,8 +614,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renaming a workspace copies its settings; the guest switch must travel with them,
|
||||
/// or the rename silently shuts every guest app of the workspace.
|
||||
/// Renaming a workspace copies its settings; the guest switch and the guest JWT key must
|
||||
/// travel with them, or the rename silently shuts every guest app or drops the key.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -629,6 +629,11 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET guest_jwt_public_key = 'test-pem-key' WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
|
||||
@@ -645,6 +650,16 @@ async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(enabled, "the guest switch travels with the workspace");
|
||||
let jwt_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
jwt_key.as_deref(),
|
||||
Some("test-pem-key"),
|
||||
"the guest JWT key travels with the workspace"
|
||||
);
|
||||
let moved: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace-2')
|
||||
AND NOT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace')",
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -25,6 +25,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,18 @@ impl AuthCache {
|
||||
if is_no_auth() {
|
||||
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
|
||||
}
|
||||
// Reject an oversized guest bearer before the cache key is built from it: the key
|
||||
// copies and hashes the whole token, so the cap should bound that work too. Log it
|
||||
// like the other guest refusals, since get_opt_job_authed turns None into a bare 401.
|
||||
if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX)
|
||||
&& token.len() > windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
|
||||
{
|
||||
tracing::error!(
|
||||
"guest JWT refused: bearer is longer than {} bytes",
|
||||
windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let key = (
|
||||
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
|
||||
token.to_string(),
|
||||
@@ -216,6 +228,111 @@ impl AuthCache {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX) => {
|
||||
// A workspace-less route never accepts a guest JWT: the identity is
|
||||
// pinned to the workspace its claim names, like a DB guest session.
|
||||
let Some(w_id) = w_id.as_deref() else {
|
||||
return None;
|
||||
};
|
||||
// Strip exactly one prefix: `trim_start_matches` would strip repeated prefixes,
|
||||
// so `jwt_guest_jwt_guest_<jwt>` would reduce to a valid token that verifies and
|
||||
// is then cached under the full, non-canonical bearer key.
|
||||
let jwt = token
|
||||
.strip_prefix(windmill_common::guest_jwt::BEARER_PREFIX)
|
||||
.unwrap_or(token);
|
||||
let claims =
|
||||
match windmill_common::guest_jwt::verify_for_workspace(&self.db, w_id, jwt)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT auth error for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The workspace switch, the instance switch and the app being in guest
|
||||
// mode, in one answer (guest_app_admits). The door re-reads the switches
|
||||
// and the no-account rule per request through the sentinel below
|
||||
// (guest_session_stands), so turning any of them off stops a cached JWT
|
||||
// session on its next call.
|
||||
match windmill_common::workspaces::guest_app_admits(
|
||||
&self.db,
|
||||
w_id,
|
||||
&claims.app_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT admit check failed for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Resolve on the lowercased email: accounts are stored lowercased, so a
|
||||
// mixed-case claim would otherwise slip past the no-account gate and
|
||||
// resolve an account holder to a guest, and split the activity rows the
|
||||
// seat count reads.
|
||||
let email = claims.email.to_lowercase();
|
||||
// A guest is someone with no account at all; an account holder is refused,
|
||||
// never downgraded (the same rule as the signed-in guest mint).
|
||||
match windmill_common::users::has_any_account(&self.db, &email).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT account check failed: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// The instance allowance, checked and recorded transactionally. A stranger
|
||||
// past the cap on a capped instance is refused here; a returning guest
|
||||
// always passes. Recording an account holder is avoided by the check above.
|
||||
if !admit_and_record_guest_jwt(&self.db, w_id, &email, &claims.app_path).await {
|
||||
return None;
|
||||
}
|
||||
// guest_session_scopes already carries the sentinel, and it is the whole
|
||||
// grant; a JWT has no label, so the sentinel is what governs it. It also
|
||||
// re-checks the path holds no scope metacharacter (verify already did).
|
||||
let scopes = match crate::scopes::guest_session_scopes(&claims.app_path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT app_path cannot be scoped for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The JWT's own expiry caps a token minted from this session. The auth
|
||||
// cache entry itself is capped far shorter (GUEST_JWT_CACHE_TTL) so a
|
||||
// rotated or cleared key stops the session on re-verification, within
|
||||
// minutes, rather than only at exp (up to 24h away).
|
||||
let credential_expiry =
|
||||
chrono::Utc.timestamp_nanos(claims.exp as i64 * 1_000_000_000);
|
||||
let cache_expiry = credential_expiry.min(chrono::Utc::now() + GUEST_JWT_CACHE_TTL);
|
||||
let authed = ApiAuthed {
|
||||
username: email.clone(),
|
||||
email,
|
||||
is_admin: false,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override: None,
|
||||
username_override_is_token_label: false,
|
||||
is_session_token: false,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: Some(credential_expiry),
|
||||
};
|
||||
AUTH_CACHE.insert(
|
||||
key,
|
||||
ExpiringAuthCache {
|
||||
authed: authed.clone(),
|
||||
expiry: cache_expiry,
|
||||
job_id: None,
|
||||
},
|
||||
);
|
||||
Some(OptJobAuthed { authed, job_id: None })
|
||||
}
|
||||
_ if token.starts_with("jwt_") => {
|
||||
let jwt_token = token.trim_start_matches("jwt_");
|
||||
|
||||
@@ -249,6 +366,7 @@ impl AuthCache {
|
||||
token_prefix: claims.audit_span,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
};
|
||||
// Fail closed: a `job_id` claim that does not parse must reject
|
||||
// the token rather than resolve to `None`, which would clear the
|
||||
@@ -363,6 +481,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@@ -416,6 +535,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@@ -494,6 +614,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
None if super_admin => {
|
||||
@@ -518,6 +639,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
@@ -555,6 +677,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@@ -574,6 +697,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -612,6 +736,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
};
|
||||
Some(OptJobAuthed { authed, job_id: None })
|
||||
} else {
|
||||
@@ -622,6 +747,127 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a guest JWT resolves from the auth cache before the arm re-runs (and
|
||||
/// re-reads the key). A guest JWT is not revocable except by the workspace switch or
|
||||
/// by rotating the key, so the entry must be short enough that a rotated key bites
|
||||
/// soon, unlike a normal token whose row can be deleted. Also what makes the
|
||||
/// day-keyed activity dedupe below reachable across a midnight.
|
||||
const GUEST_JWT_CACHE_TTL: chrono::Duration = chrono::Duration::minutes(5);
|
||||
|
||||
/// A refused JWT (a stranger past the allowance) is remembered this long so a replayed
|
||||
/// bearer does not take the instance-wide allowance advisory lock on every request.
|
||||
/// Short, so a stranger admitted once the window frees is re-checked soon.
|
||||
const GUEST_JWT_REFUSED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// One `guest_activity` upsert and one `users.login_guest` audit per email,
|
||||
// workspace and day: the arm re-runs every GUEST_JWT_CACHE_TTL, and neither the
|
||||
// seat scan nor the audit trail wants a write each time. LRU-bounded; the day is in
|
||||
// the key, so a new day writes again.
|
||||
static ref GUEST_JWT_ACTIVITY_CACHE: Cache<String, ()> = Cache::new(2000);
|
||||
static ref GUEST_JWT_REFUSED_CACHE: Cache<String, std::time::Instant> = Cache::new(2000);
|
||||
}
|
||||
|
||||
/// Admit a JWT guest against the instance allowance and record today's activity, in one
|
||||
/// transaction so the advisory lock in `guest_admission` spans the count check and the
|
||||
/// row that changes it. Returns false when the allowance refuses the email or on a DB
|
||||
/// error, both of which deny the guest. Cached per email, workspace and day: a bearer
|
||||
/// replayed every request runs this at most once a day, and a refused one is remembered
|
||||
/// briefly so it does not re-take the allowance lock. `email` is already lowercased.
|
||||
async fn admit_and_record_guest_jwt(db: &DB, w_id: &str, email: &str, app_path: &str) -> bool {
|
||||
let cache_key = format!("{email}|{w_id}|{}", chrono::Utc::now().date_naive());
|
||||
if GUEST_JWT_ACTIVITY_CACHE.get(&cache_key).is_some() {
|
||||
return true;
|
||||
}
|
||||
if GUEST_JWT_REFUSED_CACHE
|
||||
.get(&cache_key)
|
||||
.is_some_and(|at| at.elapsed() < GUEST_JWT_REFUSED_TTL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut tx = match db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT tx begin failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// The allowance and the row that changes it, in one transaction: guest_admission
|
||||
// takes a transaction-scoped advisory lock, so the count check and the insert cannot
|
||||
// race two strangers past the cap. Only a real allowance refusal is negative-cached;
|
||||
// a transient DB error denies this request but must not lock the email out for 30s.
|
||||
match windmill_common::workspaces::guest_admission(&mut *tx, email).await {
|
||||
Ok(()) => {}
|
||||
Err(e @ windmill_common::error::Error::PermissionDenied(_)) => {
|
||||
// The guest hits a bare 401 (the reason must not leak to an unauthenticated caller);
|
||||
// warn so an admin sees the cap in logs, since it is the actionable signal here.
|
||||
tracing::warn!("guest JWT refused (guest allowance) for {w_id}: {e:#}");
|
||||
GUEST_JWT_REFUSED_CACHE.insert(cache_key, std::time::Instant::now());
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT allowance check failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The conditional `WHERE NOT jwt_entry` flips the flag only on its false-to-true
|
||||
// transition, so the upsert returns a row exactly once per email per day: on the
|
||||
// fresh insert, or on the first JWT after an identity-provider sign-in created
|
||||
// today's row with `jwt_entry = false`. The audit is gated on that, decided
|
||||
// atomically by the conflicting tuple, so concurrent first requests (a metered
|
||||
// instance takes no advisory lock) audit at most once.
|
||||
let first_jwt = sqlx::query_scalar!(
|
||||
r#"INSERT INTO guest_activity (email, workspace_id, day, jwt_entry)
|
||||
VALUES ($1, $2, CURRENT_DATE, true)
|
||||
ON CONFLICT (email, workspace_id, day)
|
||||
DO UPDATE SET jwt_entry = true, last_seen_at = now()
|
||||
WHERE NOT guest_activity.jwt_entry
|
||||
RETURNING 1 AS "audited!""#,
|
||||
email,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
let first_jwt = match first_jwt {
|
||||
Ok(v) => v.is_some(),
|
||||
Err(e) => {
|
||||
tracing::error!("recording guest JWT activity for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = tx.commit().await {
|
||||
tracing::error!("guest JWT tx commit failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
GUEST_JWT_ACTIVITY_CACHE.insert(cache_key, ());
|
||||
// Audit last, best-effort, on its own connection: the EE writer swallows an
|
||||
// `audit_partitioned` failure but that failing statement still aborts the
|
||||
// transaction it runs in, so auditing before the commit would let the whole
|
||||
// activity row roll back while this returned success, admitting an uncounted guest.
|
||||
if first_jwt {
|
||||
let author = windmill_common::audit::AuditAuthor {
|
||||
email: email.to_string(),
|
||||
username: email.to_string(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
};
|
||||
if let Err(e) = windmill_audit::audit_oss::audit_log(
|
||||
db,
|
||||
&author,
|
||||
"users.login_guest",
|
||||
windmill_audit::ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
Some([("entry", "jwt")].into()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("auditing guest JWT login for {w_id}: {e:#}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
@@ -822,6 +1068,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -793,6 +793,33 @@ pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
|
||||
scopes
|
||||
}
|
||||
|
||||
/// Scopes a guest session carries. The broad-looking reads are narrowed to a route
|
||||
/// allowlist by the sentinel (`guest_route_denied`), plus the two path-scoped app
|
||||
/// grants. A guest has no `usr` row, so this list is the whole of what it can do. The
|
||||
/// single source both the mint (a signed-in guest) and the JWT auth arm build from.
|
||||
///
|
||||
/// The sentinel here only narrows. A signed-in guest is made one by the server-minted
|
||||
/// label; a JWT guest has no label, so for it the sentinel is what governs.
|
||||
pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Vec<String>> {
|
||||
// The path is spliced into a scope, whose grammar reserves `:`, `,`, `*` and a leading
|
||||
// `/`; app paths may otherwise carry spaces and `@`, so guard only those reserved chars.
|
||||
if !windmill_common::auth::is_scope_literal_path(app_path) {
|
||||
return Err(windmill_common::error::Error::BadRequest(format!(
|
||||
"app path {app_path} is empty or cannot be scoped: `:`, `,` and `*` are reserved \
|
||||
in scopes, and a leading `/` never matches a route"
|
||||
)));
|
||||
}
|
||||
Ok(vec![
|
||||
GUEST_SENTINEL.to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
"users:read".to_string(),
|
||||
"folders:read".to_string(),
|
||||
format!("apps:read:{app_path}"),
|
||||
format!("apps:run:{app_path}"),
|
||||
])
|
||||
}
|
||||
|
||||
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
|
||||
/// to narrow the declared scopes to what the viewer's prompt promised.
|
||||
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
|
||||
|
||||
@@ -15,6 +15,12 @@ use windmill_test_utils::*;
|
||||
|
||||
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
|
||||
|
||||
/// A bun lock the executor accepts without installing anything: no dependencies
|
||||
/// in the `package.json` half, `<empty>` for the `bun.lock` half. The empty
|
||||
/// string is not a substitute: a lock carrying no `//bun.lock` separator is
|
||||
/// rejected at run time.
|
||||
const EMPTY_BUN_LOCK: &str = "{}\n//bun.lock\n<empty>";
|
||||
|
||||
/// Echoes the two halves of the event separately, so the assertions can tell
|
||||
/// which one a value arrived in.
|
||||
const PREPROCESSOR_SCRIPT: &str = r#"
|
||||
@@ -84,7 +90,7 @@ async fn test_mcp_preprocessor_receives_the_callers_headers(
|
||||
"description": "",
|
||||
"content": PREPROCESSOR_SCRIPT,
|
||||
"language": "bun",
|
||||
"lock": "",
|
||||
"lock": EMPTY_BUN_LOCK,
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
@@ -101,13 +107,14 @@ async fn test_mcp_preprocessor_receives_the_callers_headers(
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
// A script counts as deployed once it has a lock, which normally arrives from
|
||||
// a dependency job. Planting an empty one keeps the test to the path under
|
||||
// test instead of a bun resolution whose timing it does not control.
|
||||
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
|
||||
.bind(SCRIPT_PATH)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// A supplied lock queues no dependency job, so the version is deployed (hence
|
||||
// listable and runnable) as soon as the create returns.
|
||||
let queued: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM v2_job_queue WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(queued, 0, "the supplied lock must queue no dependency job");
|
||||
|
||||
let tools = mcp_post(
|
||||
port,
|
||||
|
||||
@@ -63,6 +63,7 @@ fn test_authed() -> ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2938,31 +2938,6 @@ lazy_static::lazy_static! {
|
||||
.unwrap_or(8 * 60 * 60);
|
||||
}
|
||||
|
||||
/// Scopes a guest session carries. Mirrors `APP_EMBED_SCOPES` — the same broad-looking
|
||||
/// reads narrowed to a route allowlist by the sentinel (`guest_route_denied`) — plus the
|
||||
/// two path-scoped app grants minted per app. With no ACL of its own, this list is the
|
||||
/// whole of what a guest can do.
|
||||
///
|
||||
/// The `guest` sentinel here only narrows. What makes the session a guest at all is the
|
||||
/// server-minted label ([`windmill_common::auth::GUEST_SESSION_LABEL`]).
|
||||
fn guest_session_scopes(app_path: &str) -> Result<Vec<String>> {
|
||||
if !windmill_common::auth::is_scope_literal_path(app_path) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"app path {app_path} cannot be scoped: `:`, `,` and `*` are reserved in scopes, \
|
||||
and a leading `/` never matches a route"
|
||||
)));
|
||||
}
|
||||
Ok(vec![
|
||||
windmill_api_auth::scopes::GUEST_SENTINEL.to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
"users:read".to_string(),
|
||||
"folders:read".to_string(),
|
||||
format!("apps:read:{app_path}"),
|
||||
format!("apps:run:{app_path}"),
|
||||
])
|
||||
}
|
||||
|
||||
/// Mint a browser session for someone the identity provider authenticated who is a
|
||||
/// member of no workspace, so they can open one guest-mode app. Writes no `password`
|
||||
/// and no `usr` row: that absence is what keeps a guest off every seat counter, so
|
||||
@@ -2994,20 +2969,11 @@ pub async fn create_guest_session_token<'c>(
|
||||
} else {
|
||||
Some(&token)
|
||||
};
|
||||
let scopes = guest_session_scopes(app_path)?;
|
||||
let scopes = windmill_api_auth::scopes::guest_session_scopes(app_path)?;
|
||||
|
||||
// No account at all (see `ExecutionMode::Guest`): a deactivated `password` row
|
||||
// counts, since the sign-in path's own lookup filters on `disabled = false` and a
|
||||
// SCIM-offboarded account would otherwise read as absent; so does a `usr` row in
|
||||
// any workspace, which is what a service account has instead of a password.
|
||||
let has_account: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)
|
||||
OR EXISTS(SELECT 1 FROM usr WHERE email = $1)",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if has_account {
|
||||
// No account at all (see `has_any_account`): an account holder is refused a guest
|
||||
// session, never handed a second, cheaper identity. The same helper the JWT arm uses.
|
||||
if windmill_common::users::has_any_account(&mut **tx, email).await? {
|
||||
return Err(Error::NotAuthorized(
|
||||
"an existing account cannot hold a guest session".to_string(),
|
||||
));
|
||||
@@ -3061,7 +3027,7 @@ pub async fn create_guest_session_token<'c>(
|
||||
ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
None,
|
||||
Some([("entry", "idp")].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -416,6 +416,16 @@ async fn run_datatable_migrations(
|
||||
|
||||
let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?;
|
||||
|
||||
// How the user scoped the run, for the counter emitted on the first migration
|
||||
// that lands below.
|
||||
let scope = if query.only.is_some() {
|
||||
"only"
|
||||
} else if query.up_to.is_some() {
|
||||
"up_to"
|
||||
} else {
|
||||
"all"
|
||||
};
|
||||
|
||||
let mut applied = Vec::new();
|
||||
for m in migrations {
|
||||
if let Some(only) = query.only {
|
||||
@@ -453,6 +463,14 @@ async fn run_datatable_migrations(
|
||||
))
|
||||
})?;
|
||||
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
|
||||
// One event per run that moved the data table forward, emitted on the
|
||||
// first migration that lands rather than after the loop: a later one
|
||||
// failing returns early, and that run still advanced the data table. A
|
||||
// run with nothing pending stays uncounted — it is the common outcome of
|
||||
// opening the list and would drown out the runs that did something.
|
||||
if applied.len() == 1 {
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migration_run", scope);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(RunDatatableMigrationsResult { applied }))
|
||||
@@ -594,6 +612,12 @@ async fn rollback_datatable_migrations(
|
||||
))
|
||||
})?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_rollback",
|
||||
if query.only.is_some() { "only" } else { "last" },
|
||||
);
|
||||
|
||||
Ok(Json(RollbackDatatableMigrationsResult {
|
||||
rolled_back: vec![RolledBackMigration { version, name: definition.name }],
|
||||
}))
|
||||
@@ -824,6 +848,8 @@ async fn enable_datatable_migrations(
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "on");
|
||||
|
||||
Ok(format!(
|
||||
"Enabled migrations for data table {datatable_name}"
|
||||
))
|
||||
@@ -892,6 +918,8 @@ async fn disable_datatable_migrations(
|
||||
.await?;
|
||||
}
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "off");
|
||||
|
||||
Ok(format!(
|
||||
"Disabled migrations for data table {datatable_name} and deleted its migrations"
|
||||
))
|
||||
@@ -1134,6 +1162,8 @@ async fn create_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migration_created", "manual");
|
||||
|
||||
Ok(Json(DatatableMigration {
|
||||
datatable: datatable_name,
|
||||
timestamp,
|
||||
@@ -1371,6 +1401,20 @@ async fn upsert_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An unchanged re-push is not counted: `wmill sync push` sends every migration
|
||||
// on every sync, so counting those would swamp the definitions people write.
|
||||
if !unchanged {
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_created",
|
||||
if existing.is_none() {
|
||||
"synced"
|
||||
} else {
|
||||
"edited"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Upserted migration {} in {}",
|
||||
payload.timestamp, datatable_name
|
||||
@@ -1477,6 +1521,12 @@ async fn generate_initial_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_created",
|
||||
"initial_snapshot",
|
||||
);
|
||||
|
||||
Ok(Json(DatatableMigration {
|
||||
datatable: datatable_name,
|
||||
timestamp,
|
||||
|
||||
@@ -12,10 +12,10 @@ use windmill_api_auth::{
|
||||
};
|
||||
use windmill_api_users::users::WorkspaceInvite;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||||
use windmill_common::webhook::WebhookShared;
|
||||
use windmill_common::{BASE_URL, DB};
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -152,6 +152,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
|
||||
.route("/edit_default_app", post(edit_default_app))
|
||||
.route("/edit_guest_access", post(edit_guest_access))
|
||||
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
|
||||
.route("/guest_usage", get(get_guest_usage))
|
||||
.route("/default_app", get(get_default_app))
|
||||
.route(
|
||||
@@ -322,6 +323,14 @@ pub struct WorkspaceSettings {
|
||||
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
|
||||
/// own `execution_mode: guest` is inert while this is off.
|
||||
pub guest_access_enabled: bool,
|
||||
/// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most
|
||||
/// one (a DB CHECK enforces it). Public material, not a secret, so it is admin-
|
||||
/// readable here. `None`/`None` falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guest_jwt_public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guest_jwt_jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
|
||||
@@ -1082,7 +1091,9 @@ async fn get_settings(
|
||||
success_handler,
|
||||
public_app_execution_limit_per_minute,
|
||||
error_handler_fallback_to_instance_alerts,
|
||||
guest_access_enabled
|
||||
guest_access_enabled,
|
||||
guest_jwt_public_key,
|
||||
guest_jwt_jwks_url
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
@@ -3513,6 +3524,9 @@ async fn edit_datatable_config(
|
||||
// Migrations opt-in is owned by the enable/disable endpoints, not this config
|
||||
// form: preserve each existing data table's flag, and default brand-new data
|
||||
// tables to enabled.
|
||||
// Counted here rather than after the write because this is where a rename is
|
||||
// still distinguishable from a creation; emitted once the commit lands.
|
||||
let mut created_substrates: Vec<&'static str> = Vec::new();
|
||||
for (name, dt) in new_config.settings.datatables.iter_mut() {
|
||||
let lookup = rename_src
|
||||
.get(name.as_str())
|
||||
@@ -3520,7 +3534,15 @@ async fn edit_datatable_config(
|
||||
.unwrap_or(name.as_str());
|
||||
dt.migrations_enabled = match old_datatables.get(lookup) {
|
||||
Some(old) => old.migrations_enabled,
|
||||
None => Some(true),
|
||||
None => {
|
||||
// Keyed by how the substrate is serialized into `workspace_settings`,
|
||||
// so these line up with the `datatable_configured` adoption counts.
|
||||
created_substrates.push(match dt.database.resource_type {
|
||||
DataTableCatalogResourceType::Instance => "instance",
|
||||
DataTableCatalogResourceType::Postgresql => "postgresql",
|
||||
});
|
||||
Some(true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3578,6 +3600,10 @@ async fn edit_datatable_config(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
for substrate in created_substrates {
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "created", substrate);
|
||||
}
|
||||
|
||||
crate::datatable_migrations::record_datatable_cascade_deployments(
|
||||
&authed,
|
||||
&db,
|
||||
@@ -4661,6 +4687,66 @@ async fn edit_guest_access(
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditGuestJwtKey {
|
||||
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
|
||||
/// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`.
|
||||
public_key: Option<String>,
|
||||
jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
|
||||
/// Workspace-admin gated, like the guest switch: guests are free up to the instance
|
||||
/// allowance on any plan, so configuring their key needs no licence. The key is
|
||||
/// validated before it is stored so a typo is refused here, not silently on every guest
|
||||
/// later: a PEM must parse as an RS/ES public key (HS* has no PEM form and is
|
||||
/// unreachable), and a JWKS URL must be fetchable and hold at least one usable signing key.
|
||||
async fn edit_guest_jwt_key(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(EditGuestJwtKey { public_key, jwks_url }): Json<EditGuestJwtKey>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let public_key = public_key.filter(|s| !s.trim().is_empty());
|
||||
let jwks_url = jwks_url.filter(|s| !s.trim().is_empty());
|
||||
if public_key.is_some() && jwks_url.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"Set a PEM public key or a JWKS URL, not both".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(pem) = public_key.as_deref() {
|
||||
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
|
||||
}
|
||||
if let Some(url) = jwks_url.as_deref() {
|
||||
windmill_common::guest_jwt::fetch_jwks(url).await?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
|
||||
public_key,
|
||||
jwks_url,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_guest_jwt_key",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Guest JWT key updated for workspace {w_id}"))
|
||||
}
|
||||
|
||||
async fn edit_default_scripts(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -11172,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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11204,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, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.803.0
|
||||
version: 1.804.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -3785,6 +3785,12 @@ paths:
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
guest_jwt_public_key:
|
||||
type: string
|
||||
description: PEM public key a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_jwks_url`.
|
||||
guest_jwt_jwks_url:
|
||||
type: string
|
||||
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -5808,6 +5814,43 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_jwt_key:
|
||||
post:
|
||||
summary: set the key guest JWTs are verified against for this workspace
|
||||
description: >-
|
||||
A guest JWT (`jwt_guest_`) is minted by the embedding customer's own backend and
|
||||
verified against this key: a PEM public key (RS/ES family, HS* refused) or a JWKS
|
||||
URL, at most one. Both empty clears the workspace key; off cloud, verification then
|
||||
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
|
||||
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
|
||||
The key is validated before it is stored.
|
||||
operationId: editGuestJwtKey
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: The guest JWT verification key
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
public_key:
|
||||
type: string
|
||||
description: A PEM public key (RS or ES family).
|
||||
jwks_url:
|
||||
type: string
|
||||
description: A JWKS URL whose keys are fetched and refreshed.
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/guest_usage:
|
||||
get:
|
||||
summary: the instance's standing against the guest allowance
|
||||
|
||||
@@ -1520,20 +1520,24 @@ async fn guest_derived_token_constraints(
|
||||
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
|
||||
return Ok(None);
|
||||
}
|
||||
// The minter is known by prefix only; MIN over a (theoretical) prefix collision is
|
||||
// the conservative side.
|
||||
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
|
||||
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
|
||||
)
|
||||
.bind(authed.token_prefix.as_deref().unwrap_or(""))
|
||||
.bind(&authed.email)
|
||||
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let Some(parent_exp) = parent.flatten() else {
|
||||
return Err(Error::NotAuthorized(
|
||||
"guest session not found or has no expiry".to_string(),
|
||||
));
|
||||
// A guest JWT carries its own expiry and has no token row to look up; a signed-in
|
||||
// guest session is a row found by prefix (MIN is the conservative side of a
|
||||
// theoretical prefix collision). Either way the derived token caps on it, never on
|
||||
// a fresh interval.
|
||||
let parent_exp = if let Some(exp) = authed.credential_expiry {
|
||||
exp
|
||||
} else {
|
||||
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
|
||||
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
|
||||
)
|
||||
.bind(authed.token_prefix.as_deref().unwrap_or(""))
|
||||
.bind(&authed.email)
|
||||
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
parent.flatten().ok_or_else(|| {
|
||||
Error::NotAuthorized("guest session not found or has no expiry".to_string())
|
||||
})?
|
||||
};
|
||||
Ok(Some((
|
||||
windmill_common::auth::GUEST_SESSION_LABEL.to_string(),
|
||||
|
||||
@@ -11716,6 +11716,7 @@ mod approval_view_gate_tests {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,7 @@ async fn inject_agent_authed(
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
},
|
||||
job_id: None,
|
||||
});
|
||||
|
||||
@@ -1421,6 +1421,7 @@ mod tests {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ pep440_rs.workspace = true
|
||||
systemstat.workspace = true
|
||||
size.workspace = true
|
||||
rsa = { workspace = true, optional = true }
|
||||
spki = { workspace = true }
|
||||
pkcs1 = { workspace = true }
|
||||
aes-gcm = { workspace = true, optional = true }
|
||||
|
||||
semver.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -69,7 +69,11 @@ class WindmillFinder(MetaPathFinder):
|
||||
r = response.read().decode("utf-8")
|
||||
if r == "WINDMILL_IS_FOLDER":
|
||||
return ModuleSpec(name, WindmillLoader(name))
|
||||
with open(fullpath, "w+") as f:
|
||||
# Python parses .py as UTF-8 regardless of locale, so the
|
||||
# file has to be written as UTF-8. Without this the ANSI
|
||||
# code page on a Windows worker re-encodes every
|
||||
# non-ASCII literal and the import dies on a SyntaxError.
|
||||
with open(fullpath, "w+", encoding="utf-8") as f:
|
||||
f.write(r)
|
||||
return spec_from_file_location(name, fullpath)
|
||||
except urllib.error.HTTPError as e:
|
||||
|
||||
@@ -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 {}",
|
||||
|
||||
@@ -117,6 +117,12 @@ const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download
|
||||
const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto");
|
||||
pub const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py");
|
||||
|
||||
/// Every file exchanged with a job is UTF-8 by construction, so the interpreter
|
||||
/// must agree. A job env carries no locale: Linux then picks UTF-8 on its own
|
||||
/// (PEP 540), Windows picks the ANSI code page. Applied after the whitelisted
|
||||
/// envs so the protocol is not the user's to opt out of.
|
||||
pub const PYTHON_UTF8_ENVS: [(&str, &str); 1] = [("PYTHONUTF8", "1")];
|
||||
|
||||
/// Render loader.py with the TEMP_SCRIPT_REFS placeholder substituted by a
|
||||
/// Python dict literal. Preview jobs pass a path -> temp-hash map so relative
|
||||
/// imports resolve from not-yet-deployed local content; deployed runs pass
|
||||
@@ -818,7 +824,7 @@ pub async fn handle_python_job(
|
||||
del pre_args[k]
|
||||
kwargs = inner_script.preprocessor(**pre_args)
|
||||
kwrags_json = res_to_json(kwargs, type(kwargs))
|
||||
with open("args.json", 'w') as f:
|
||||
with open("args.json", 'w', encoding="utf-8") as f:
|
||||
f.write(kwrags_json)"#
|
||||
)
|
||||
} else {
|
||||
@@ -853,7 +859,7 @@ pub async fn handle_python_job(
|
||||
_pre_result = asyncio.run(_pre_result)
|
||||
kwargs = _pre_result if _pre_result is not None else {{}}
|
||||
_pre_json = json.dumps(kwargs, separators=(',', ':'), default=str)
|
||||
with open("args.json", 'w') as f:
|
||||
with open("args.json", 'w', encoding="utf-8") as f:
|
||||
f.write(_pre_json)
|
||||
sys.stdout.write("wm_res[preprocessed_args]:" + _pre_json + "\n")
|
||||
sys.stdout.flush()"#
|
||||
@@ -874,11 +880,11 @@ import sys
|
||||
from {module_dir_dot} import {last} as inner_script
|
||||
from wmill.client import _run_workflow
|
||||
|
||||
with open("args.json") as f:
|
||||
with open("args.json", encoding="utf-8") as f:
|
||||
kwargs = json.load(f, strict=False)
|
||||
{transforms}
|
||||
|
||||
with open("checkpoint.json") as f:
|
||||
with open("checkpoint.json", encoding="utf-8") as f:
|
||||
checkpoint = json.load(f, strict=False)
|
||||
|
||||
result_json = os.path.join(os.path.abspath(os.path.dirname(__file__)), "result.json")
|
||||
@@ -904,12 +910,12 @@ try:
|
||||
print("")
|
||||
print("--- WAC: complete ---")
|
||||
output_json = json.dumps(output, separators=(',', ':'), default=str)
|
||||
with open(result_json, 'w') as f:
|
||||
with open(result_json, 'w', encoding="utf-8") as f:
|
||||
f.write(output_json)
|
||||
except BaseException as e:
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
tb = traceback.format_tb(exc_traceback)
|
||||
with open(result_json, 'w') as f:
|
||||
with open(result_json, 'w', encoding="utf-8") as f:
|
||||
err = {{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}
|
||||
extra = e.__dict__
|
||||
if extra and len(extra) > 0:
|
||||
@@ -936,7 +942,7 @@ import sys
|
||||
from {module_dir_dot} import {last} as inner_script
|
||||
import re
|
||||
|
||||
with open("args.json") as f:
|
||||
with open("args.json", encoding="utf-8") as f:
|
||||
kwargs = json.load(f, strict=False)
|
||||
args = {{}}
|
||||
{transforms}
|
||||
@@ -972,12 +978,12 @@ try:
|
||||
print("WM_STREAM: " + chunk.replace('\n', '\\n'))
|
||||
res = None
|
||||
res_json = res_to_json(res, typ)
|
||||
with open(result_json, 'w') as f:
|
||||
with open(result_json, 'w', encoding="utf-8") as f:
|
||||
f.write(res_json)
|
||||
except BaseException as e:
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
tb = traceback.format_tb(exc_traceback)
|
||||
with open(result_json, 'w') as f:
|
||||
with open(result_json, 'w', encoding="utf-8") as f:
|
||||
err = {{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}
|
||||
extra = e.__dict__
|
||||
if extra and len(extra) > 0:
|
||||
@@ -1117,6 +1123,7 @@ mount {{
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
.envs(PYTHON_UTF8_ENVS)
|
||||
.env("PATH", PATH_ENV.as_str())
|
||||
.env("TZ", TZ_ENV.as_str())
|
||||
.env("BASE_INTERNAL_URL", base_internal_url)
|
||||
@@ -1152,6 +1159,7 @@ mount {{
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
.envs(PYTHON_UTF8_ENVS)
|
||||
.env("PATH", PATH_ENV.as_str())
|
||||
.env("TZ", TZ_ENV.as_str())
|
||||
.env("BASE_INTERNAL_URL", base_internal_url)
|
||||
@@ -1223,6 +1231,7 @@ mount {{
|
||||
result,
|
||||
job,
|
||||
conn,
|
||||
canceled_by,
|
||||
modules,
|
||||
new_args.as_ref(),
|
||||
))
|
||||
@@ -3430,7 +3439,10 @@ pub async fn start_worker(
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut proc_envs = HashMap::new();
|
||||
let mut proc_envs: HashMap<String, String> = PYTHON_UTF8_ENVS
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
let additional_python_paths_folders = additional_python_paths.iter().join(":");
|
||||
proc_envs.insert("PYTHONPATH".to_string(), additional_python_paths_folders);
|
||||
proc_envs.insert("PATH".to_string(), PATH_ENV.to_string());
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -209,33 +209,24 @@
|
||||
U+1fac6, U+1fae0-1fae6, U+1fae8-1faea, U+1faef-1faf8;
|
||||
}
|
||||
|
||||
.prose-xs ul {
|
||||
margin-top: 0.5rem;
|
||||
list-style-type: '- ';
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Bullets read as a dash rather than a disc. Only the glyph is overridden:
|
||||
indentation and vertical rhythm stay with Tailwind Typography so ordered
|
||||
and unordered lists line up with each other. */
|
||||
.prose ul {
|
||||
margin-top: 1.5rem;
|
||||
list-style-type: '- ';
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
/* The '- ' list markers, horizontal rules and blockquote bars otherwise
|
||||
fall through to Tailwind Typography's default bullet/border colors, which
|
||||
are nearly invisible on dark backgrounds (e.g. the AI chat). Use
|
||||
theme-aware tokens so they stay readable in both light and dark mode. */
|
||||
.prose-xs ul > li::marker,
|
||||
.prose ul > li::marker {
|
||||
/* List markers, horizontal rules and blockquote bars take the tertiary/light
|
||||
tokens the typography config maps them to, which is too faint to read on
|
||||
the denser markdown surfaces (e.g. the AI chat). Step them up one. */
|
||||
.prose :is(ul, ol) > li::marker {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
}
|
||||
|
||||
.prose-xs hr,
|
||||
.prose hr {
|
||||
border-top-color: rgb(var(--color-border-normal));
|
||||
}
|
||||
|
||||
.prose-xs blockquote,
|
||||
.prose blockquote {
|
||||
border-left-color: rgb(var(--color-border-normal));
|
||||
}
|
||||
|
||||
@@ -6,30 +6,34 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
username: string;
|
||||
isConflict?: boolean;
|
||||
noPadding?: boolean;
|
||||
email: string
|
||||
username: string
|
||||
isConflict?: boolean
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
email,
|
||||
username = $bindable(),
|
||||
isConflict = false,
|
||||
noPadding = false
|
||||
}: Props = $props();
|
||||
let { email, username = $bindable(), isConflict = false, noPadding = false }: Props = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
let usernameInfo:
|
||||
| {
|
||||
username: string
|
||||
workspace_usernames: {
|
||||
workspace_id: string
|
||||
username: string
|
||||
}[]
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
type UsernameInfo = {
|
||||
username: string
|
||||
workspace_usernames: {
|
||||
workspace_id: string
|
||||
username: string
|
||||
}[]
|
||||
}
|
||||
|
||||
let usernameInfo: UsernameInfo | undefined = $state(undefined)
|
||||
|
||||
let affectedWorkspaces = $derived.by(
|
||||
() => usernameInfo?.workspace_usernames.filter((w) => w.username !== username) ?? []
|
||||
)
|
||||
let isRenaming = $derived.by(
|
||||
() =>
|
||||
usernameInfo !== undefined &&
|
||||
(username !== usernameInfo.username || affectedWorkspaces.length > 0)
|
||||
)
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
@@ -53,6 +57,11 @@
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function renameUser() {
|
||||
// Renaming before the current usernames are known would apply a change whose scope
|
||||
// the "Manual action required" warning could not have been shown for.
|
||||
if (!usernameInfo) {
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
const automateUsernameCreation =
|
||||
@@ -102,31 +111,30 @@
|
||||
Users are required to have an instance-wide username that is shared across all workspaces.
|
||||
However, this user has different usernames in different workspaces.
|
||||
|
||||
{#if usernameInfo?.workspace_usernames && usernameInfo.workspace_usernames.filter((w) => w.username !== username).length > 0}
|
||||
{#if affectedWorkspaces.length > 0}
|
||||
<br />
|
||||
<br />
|
||||
Workspaces requiring username modification: {usernameInfo.workspace_usernames
|
||||
.filter((w) => w.username !== username)
|
||||
Workspaces requiring username modification: {affectedWorkspaces
|
||||
.map((wu) => `${wu.workspace_id} (${wu.username})`)
|
||||
.join(', ')}
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if !isConflict && usernameInfo?.workspace_usernames && usernameInfo.workspace_usernames.filter((w) => w.username !== username).length > 0}
|
||||
{#if !isConflict && affectedWorkspaces.length > 0}
|
||||
<Alert title="Concerned workspaces" class="mb-4">
|
||||
{usernameInfo.workspace_usernames
|
||||
.filter((w) => w.username !== username)
|
||||
.map((wu) => `${wu.workspace_id}`)
|
||||
.join(', ')}
|
||||
{affectedWorkspaces.map((wu) => `${wu.workspace_id}`).join(', ')}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<Alert type="warning" title="Manual action required" class="mb-4">
|
||||
This operation does not handle references in scripts, workflows and applications to scripts in
|
||||
the workspace, and references in resources to variables. You will have to handle those manually.
|
||||
<br />
|
||||
</Alert>
|
||||
{#if isRenaming}
|
||||
<Alert type="warning" title="Manual action required" class="mb-4">
|
||||
This operation does not handle references in scripts, workflows and applications to scripts in
|
||||
the workspace, and references in resources to variables. You will have to handle those
|
||||
manually.
|
||||
<br />
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -136,7 +144,7 @@
|
||||
dispatch('close')
|
||||
})
|
||||
}}
|
||||
disabled={email === undefined || !username}
|
||||
disabled={email === undefined || !username || !usernameInfo}
|
||||
{loading}
|
||||
>
|
||||
Confirm username change
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
|
||||
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
|
||||
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
|
||||
@@ -97,9 +98,11 @@
|
||||
for (;;) {
|
||||
const choice = await promptDdl(statement)
|
||||
if (choice === 'cancel') {
|
||||
logDdlGuardChoice('cancelled')
|
||||
return { proceed: false, code, ranMigration: migrationRan }
|
||||
}
|
||||
if (choice === 'run') {
|
||||
logDdlGuardChoice('run_anyway')
|
||||
kept.push(statement)
|
||||
break
|
||||
}
|
||||
@@ -107,6 +110,7 @@
|
||||
// created; if the modal was cancelled, loop back to the prompt.
|
||||
const created = await openMigrationModal(statement)
|
||||
if (created) {
|
||||
logDdlGuardChoice('migrated')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import AutoDataTable from './table/AutoDataTable.svelte'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { markdownProse } from './markdownProse'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import FileDownload from './common/fileDownload/FileDownload.svelte'
|
||||
|
||||
@@ -1229,7 +1230,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if !forceJson && resultKind === 'markdown'}
|
||||
<div class="prose-xs dark:prose-invert !list-disc !list-outside">
|
||||
<div class={markdownProse.sm}>
|
||||
<Markdown md={result?.md ?? result?.markdown} />
|
||||
</div>
|
||||
{:else if largeObject || hasBigInt}
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
interface Props {
|
||||
md: string
|
||||
noPadding?: boolean
|
||||
/** Shared prose stack to render with. Omitted keeps the legacy `prose-xs`,
|
||||
* which the flow-graph notes are laid out against. */
|
||||
/** Shared prose stack to render with. */
|
||||
prose?: MarkdownProseSize
|
||||
}
|
||||
|
||||
let { md, noPadding, prose }: Props = $props()
|
||||
let { md, noPadding, prose = 'sm' }: Props = $props()
|
||||
|
||||
// Rendering markdown turns `` into a real `<img>`, i.e. a request. On the
|
||||
// public replay page the source is a recording from an arbitrary origin and the
|
||||
@@ -21,7 +20,7 @@
|
||||
let asPlainText = $derived(isOfflineReplay())
|
||||
</script>
|
||||
|
||||
<div class="{prose ? markdownProse[prose] : '!prose-xs'} {noPadding ? '' : 'pgap'}">
|
||||
<div class="{markdownProse[prose]} {noPadding ? '' : 'pgap'}">
|
||||
{#if asPlainText}
|
||||
<p class="whitespace-pre-wrap">{md}</p>
|
||||
{:else}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import { offset, flip, shift } from 'svelte-floating-ui/dom'
|
||||
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
|
||||
import ChangeInstanceEmailInner from './ChangeInstanceEmailInner.svelte'
|
||||
import { UserService } from '$lib/gen'
|
||||
@@ -53,11 +52,8 @@
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
floatingConfig={{
|
||||
strategy: 'fixed',
|
||||
placement: 'left-end',
|
||||
middleware: [offset(8), flip(), shift()]
|
||||
}}
|
||||
floatingConfig={{ strategy: 'fixed', placement: 'left-end' }}
|
||||
contentClasses="pt-9"
|
||||
closeButton
|
||||
>
|
||||
{#snippet trigger()}
|
||||
@@ -66,7 +62,10 @@
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-8 max-w-sm p-4">
|
||||
<!-- The scroll sits here rather than on the popover box, which is what the close button
|
||||
is positioned against — box-level overflow scrolls that button out of reach. The
|
||||
box's `pt-9` clears the button's 34px, so the scrollbar starts below it. -->
|
||||
<div class="flex flex-col gap-8 max-w-sm p-4 pt-0 max-h-[70vh] overflow-y-auto">
|
||||
<ChangeInstanceEmailInner
|
||||
{email}
|
||||
{username}
|
||||
|
||||
@@ -1076,12 +1076,15 @@
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||||
membership, and the plan tier and quota shown when the execution meter is opened, last
|
||||
30 days)</li
|
||||
membership, the plan tier and quota shown when the execution meter is opened, whether
|
||||
app sandbox isolation is turned on, whether a step's workspace script is edited from
|
||||
the flow editor, and how data tables and their migrations are set up and used, last 30
|
||||
days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
deployed items use)</li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
features your deployed items use, including how many apps run sandboxed, how many data
|
||||
tables exist per database kind, how many use migrations, and what references them)</li
|
||||
>
|
||||
<li
|
||||
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
||||
@@ -1135,12 +1138,15 @@
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||||
membership, and the plan tier and quota shown when the execution meter is opened, last
|
||||
30 days)</li
|
||||
membership, the plan tier and quota shown when the execution meter is opened, whether
|
||||
app sandbox isolation is turned on, whether a step's workspace script is edited from
|
||||
the flow editor, and how data tables and their migrations are set up and used, last 30
|
||||
days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
deployed items use)</li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
features your deployed items use, including how many apps run sandboxed, how many data
|
||||
tables exist per database kind, how many use migrations, and what references them)</li
|
||||
>
|
||||
<li
|
||||
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
if (!redirectSaml()) autoRedirecting = false
|
||||
} else if (logins?.some((l) => l.type === autoLogin)) {
|
||||
autoRedirecting = true
|
||||
if (!storeRedirect(autoLogin)) {
|
||||
if (!storeRedirect(autoLogin, true)) {
|
||||
autoRedirecting = false
|
||||
sendUserToast('Popup blocked — please click the sign-in button to continue.', true)
|
||||
}
|
||||
@@ -546,13 +546,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function storeRedirect(provider: string): boolean {
|
||||
// `automatic` marks the auto-login redirect, the one login that has to reach the
|
||||
// provider without drawing anything. It suppresses the provider's extra params —
|
||||
// Google's and Microsoft's account chooser — which every other login gets.
|
||||
function storeRedirect(provider: string, automatic: boolean): boolean {
|
||||
// The kitchen sink renders real provider buttons; clicking one must not leave the page.
|
||||
if (previewConfig) return true
|
||||
markLoginMethodPending({ kind: 'oauth', provider })
|
||||
persistRd()
|
||||
const params = new URLSearchParams()
|
||||
if (popup) params.set('close', 'true')
|
||||
if (automatic) params.set('auto', 'true')
|
||||
if (guestApp) params.set('guest_app', guestApp)
|
||||
const query = params.size > 0 ? '?' + params.toString() : ''
|
||||
let url = base + '/api/oauth/login/' + provider + query
|
||||
@@ -716,7 +720,9 @@
|
||||
unifiedSize="lg"
|
||||
startIcon={entry.icon ? { icon: entry.icon, classes: 'h-4' } : undefined}
|
||||
onClick={() =>
|
||||
entry.method.kind === 'saml' ? redirectSaml() : storeRedirect(entry.method.provider)}
|
||||
entry.method.kind === 'saml'
|
||||
? redirectSaml()
|
||||
: storeRedirect(entry.method.provider, false)}
|
||||
>
|
||||
Continue with {entry.displayName}
|
||||
</Button>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
} from '$lib/components/OnBehalfOfSelector.svelte'
|
||||
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
const WM_DEPLOYERS_GROUP = 'wm_deployers'
|
||||
|
||||
@@ -176,6 +177,10 @@
|
||||
}${customPath}`
|
||||
)
|
||||
|
||||
// The app URL a guest JWT rides on: append `guest.<jwt>` and the viewer authenticates the
|
||||
// token as a seatless guest. Uses the custom URL when set, else the public secret URL.
|
||||
let guestJwtBase = $derived(customPath !== undefined ? fullCustomUrl : secretUrlHref)
|
||||
|
||||
// When embedding a raw app in an iframe inside another Windmill app (or any
|
||||
// cross-origin-isolated page), the embedded document must set COEP. The
|
||||
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
|
||||
@@ -348,6 +353,12 @@
|
||||
checked={policy.sandbox == true}
|
||||
on:change={(e) => {
|
||||
policy.sandbox = e.detail || undefined
|
||||
// Counted where the toggle is flipped rather than where the policy is
|
||||
// persisted: a not-yet-deployed app only mutates it locally, and skipping
|
||||
// those would read as unused in the case where it is picked up front.
|
||||
logFeatureUsage('app_sandbox', 'toggled', {
|
||||
key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}`
|
||||
})
|
||||
// Frontend API access exists only for a sandboxed app, so turning
|
||||
// isolation off drops the declared scopes with it rather than leaving
|
||||
// them set but inert.
|
||||
@@ -500,8 +511,8 @@
|
||||
Anyone your identity provider authenticates can open this app without a Windmill account.
|
||||
They join no workspace. Members of this workspace can open it too.
|
||||
{#if guestUsage}
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
|
||||
instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance
|
||||
in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered
|
||||
? 'every four guests count as one seat'
|
||||
: 'new guests are refused until the count drops'}.
|
||||
{/if}
|
||||
@@ -543,6 +554,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
|
||||
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Embed for your own authenticated users (guest JWT)
|
||||
</div>
|
||||
<div class="text-xs text-secondary">
|
||||
To open this app for a user your own product already authenticates, mint a short-lived JWT
|
||||
in your backend and append it to the app URL as <code>guest.<jwt></code>. Each token
|
||||
is its own seatless guest, confined to this app — no shared secret and no Windmill
|
||||
account, unlike the plain secret URL above.
|
||||
</div>
|
||||
<div class="text-xs text-secondary">
|
||||
Windmill verifies the token against the workspace's guest JWT key (Workspace settings →
|
||||
Guests) — a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's
|
||||
configured issuer (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}. Set
|
||||
the <b>public</b> half there; in your backend, sign each token with the matching
|
||||
<b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is
|
||||
refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>,
|
||||
<code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead).
|
||||
</div>
|
||||
<ClipboardPanel
|
||||
content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)}
|
||||
size="md"
|
||||
/>
|
||||
<div class="text-2xs text-secondary">
|
||||
Replace <code>YOUR_GUEST_JWT</code> with the token your backend signs per user. Past the instance's
|
||||
free guest allowance a new guest email is refused (see the count above); guests already seen
|
||||
in the window keep working.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
<Alert type="warning" title="Admin only" size="xs">
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { sendUserToast, type Item } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getToolNameError } from '$lib/components/flows/agentToolUtils'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import autosize from '$lib/autosize'
|
||||
|
||||
interface Props {
|
||||
@@ -104,7 +105,16 @@
|
||||
if (flowModuleValue?.type !== 'script') return
|
||||
const hash =
|
||||
flowModuleValue.hash ?? (await getLatestHashForScript(flowModuleValue.path, opWs))
|
||||
$scriptEditorDrawer?.openDrawer(hash, () => {
|
||||
// Same reason the settings item below is gated: the local-dev editors publish
|
||||
// the context store but never render the drawer, so an unmounted one makes
|
||||
// this a no-op — and a no-op must not be counted as an editor open.
|
||||
const drawer = $scriptEditorDrawer
|
||||
if (!drawer) return
|
||||
logFeatureUsage('flow_step', 'script_edit', { key: 'opened' })
|
||||
// The drawer only runs this callback once a new version is deployed, so it is
|
||||
// what separates opening the editor from actually editing the script here.
|
||||
drawer.openDrawer(hash, () => {
|
||||
logFeatureUsage('flow_step', 'script_edit', { key: 'saved' })
|
||||
dispatch('reload')
|
||||
sendUserToast('Script has been updated')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import { Check, X } from 'lucide-svelte'
|
||||
import { NOTE_COLORS, NoteColor } from './noteColors'
|
||||
import { NOTE_COLORS, NOTE_TEXT_COLOR_OVERRIDE, NoteColor } from './noteColors'
|
||||
import { stopPropagation, preventDefault } from 'svelte/legacy'
|
||||
|
||||
interface Props {
|
||||
@@ -127,13 +127,13 @@
|
||||
{:else if note}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {editMode
|
||||
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {NOTE_TEXT_COLOR_OVERRIDE} {editMode
|
||||
? 'cursor-pointer'
|
||||
: ''}"
|
||||
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
|
||||
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
|
||||
>
|
||||
<GfmMarkdown md={note} noPadding />
|
||||
<GfmMarkdown md={note} prose="xs" noPadding />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
||||
@@ -105,6 +105,15 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
|
||||
}
|
||||
}
|
||||
|
||||
// A note renders its text as markdown, and the prose stack sets body, heading and
|
||||
// strong colors directly on those elements — which would beat the note color the
|
||||
// wrapper only passes down by inheritance, and leave the render mismatched against
|
||||
// the textarea shown while editing. Pin every descendant back to the note color.
|
||||
// Arbitrary value, not `text-inherit`: this config replaces the color palette outright and
|
||||
// defines no `inherit` key, so the named utility would be silently generated as nothing.
|
||||
// (Kept as a literal: Tailwind's scanner reads class names verbatim from this file.)
|
||||
export const NOTE_TEXT_COLOR_OVERRIDE = '[&_*]:!text-[inherit]'
|
||||
|
||||
// Color swatch colors for the picker (solid colors for the palette dots)
|
||||
export const NOTE_COLOR_SWATCHES: Record<NoteColor, string> = {
|
||||
[NoteColor.YELLOW]: 'bg-yellow-400',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
NoteColor,
|
||||
NOTE_COLORS,
|
||||
NOTE_TEXT_COLOR_OVERRIDE,
|
||||
DEFAULT_NOTE_COLOR,
|
||||
MIN_NOTE_WIDTH,
|
||||
MIN_NOTE_HEIGHT
|
||||
@@ -294,7 +295,8 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full text-xs rounded-md break-words overflow-hidden',
|
||||
colorConfig.text
|
||||
colorConfig.text,
|
||||
NOTE_TEXT_COLOR_OVERRIDE
|
||||
)}
|
||||
>
|
||||
<GfmMarkdown md={textForDisplay} noPadding />
|
||||
|
||||
@@ -22,13 +22,13 @@ const base =
|
||||
|
||||
// One vertical rhythm for sm/doc; heading margins stay per-preset (fixed, not
|
||||
// the plugin's em-based ones) so 'doc' can breathe more between sections.
|
||||
const rhythm = 'prose-sm leading-snug prose-ul:!pl-6'
|
||||
const rhythm = 'prose-sm leading-snug'
|
||||
|
||||
const bodyXs =
|
||||
'text-primary prose-p:text-primary prose-li:text-primary prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs prose-table:text-xs'
|
||||
|
||||
export const markdownProse = {
|
||||
xs: `${base} prose-sm leading-snug prose-ul:!pl-5 prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
|
||||
xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
|
||||
sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`,
|
||||
doc: `${base} ${rhythm} ${bodyXs} prose-headings:mt-8 prose-headings:mb-2 prose-headings:font-semibold prose-headings:text-emphasis prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs prose-pre:bg-transparent prose-pre:p-0`
|
||||
} as const
|
||||
|
||||
@@ -127,6 +127,8 @@
|
||||
{/if}
|
||||
{:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)}
|
||||
Waiting executor (<TimeAgo agoOnlyIfRecent date={job.scheduled_for || ''} />)
|
||||
{:else if 'running' in job && job.running && job.suspend}
|
||||
Suspended (created <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
|
||||
{:else}
|
||||
Waiting executor (<TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
|
||||
{/if}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import DataTableConnectionReport from './DataTableConnectionReport.svelte'
|
||||
import { useSupabaseOauth } from './supabaseOauth.svelte'
|
||||
import { probeDatatableConnection } from './datatableProbe'
|
||||
import { logDatatableWizard } from './datatableTelemetry'
|
||||
import {
|
||||
anythingClaimed,
|
||||
claimOf,
|
||||
@@ -526,11 +527,13 @@
|
||||
await loadTargetUser()
|
||||
reset(parked ?? resume)
|
||||
opened = true
|
||||
logDatatableWizard({ step: 'opened' })
|
||||
}
|
||||
|
||||
function selectProvider(key: Provider) {
|
||||
if (key === wiz.provider) return
|
||||
wiz.provider = key
|
||||
logDatatableWizard({ step: 'picked', provider: key })
|
||||
invalidate()
|
||||
if (key === 'instance') wiz.instance.dbName ??= defaultInstanceDbName()
|
||||
}
|
||||
@@ -833,6 +836,11 @@
|
||||
createdProjects
|
||||
}
|
||||
}
|
||||
// The setup's own verdict, so a data table that exists counts as done even when the
|
||||
// caller's appended `onFinishAlso` step failed after it.
|
||||
if (wiz.provider) {
|
||||
logDatatableWizard({ step: result?.ok ? 'done' : 'failed', provider: wiz.provider })
|
||||
}
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
// Anonymous counters for the data table surfaces the backend cannot see: which substrate the
|
||||
// add-wizard is pointed at and how far a run gets, and what the DDL guard talks people into.
|
||||
// Same rules as every other `logFeatureUsage` caller: aggregated counts only, and the keys
|
||||
// below are the whole vocabulary — no data table name, connection string, resource path or SQL
|
||||
// ever reaches here.
|
||||
|
||||
/** The substrate a wizard run is pointed at. Mirrors the wizard's own `Provider`. */
|
||||
export type DatatableWizardProvider = 'supabase' | 'instance' | 'resource'
|
||||
|
||||
export type DatatableWizardEvent =
|
||||
/** The wizard was opened, including a run resumed from the Supabase redirect. */
|
||||
| { step: 'opened' }
|
||||
/** A substrate was picked. Re-picking a different one counts again, by design: the
|
||||
* abandoned branch is the interesting half of a funnel. */
|
||||
| { step: 'picked'; provider: DatatableWizardProvider }
|
||||
/** A run finished, with the verdict the checklist reported. */
|
||||
| { step: 'done' | 'failed'; provider: DatatableWizardProvider }
|
||||
|
||||
export function logDatatableWizard(event: DatatableWizardEvent): void {
|
||||
const key = event.step === 'opened' ? 'opened' : `${event.step}_${event.provider}`
|
||||
logFeatureUsage('datatable', 'wizard', { key })
|
||||
}
|
||||
|
||||
export type DdlGuardChoice =
|
||||
/** The DDL was run ad-hoc, against the guard's advice. */
|
||||
| 'run_anyway'
|
||||
/** The DDL became a migration definition. */
|
||||
| 'migrated'
|
||||
/** The statement was abandoned, so nothing ran. */
|
||||
| 'cancelled'
|
||||
|
||||
/**
|
||||
* Counted once per prompt that reaches a terminal choice. Picking "create a migration" and then
|
||||
* dismissing the modal loops back to the prompt instead, and is deliberately not counted: it is
|
||||
* the same statement still undecided, not a fourth outcome.
|
||||
*/
|
||||
export function logDdlGuardChoice(choice: DdlGuardChoice): void {
|
||||
logFeatureUsage('datatable', 'ddl_guard', { key: choice })
|
||||
}
|
||||
+10
-2
@@ -1,3 +1,7 @@
|
||||
<!-- `@(root)` skips the (logged) layout on purpose: that layout renders nothing but
|
||||
"Loading user..." until `$userStore` is set, and `$userStore` is only ever filled in
|
||||
for a chosen workspace. An MCP client sends the browser straight here after login, so
|
||||
in gateway mode there is no workspace yet — picking one is what this page is for. -->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
@@ -138,9 +142,13 @@
|
||||
</script>
|
||||
|
||||
{#if !redirectUriValid}
|
||||
<p class="text-center text-sm text-primary mb-6"> Error: invalid or unsafe redirect_uri </p>
|
||||
<CenteredModal title="Authorization Request">
|
||||
<p class="text-center text-sm text-primary"> Error: invalid or unsafe redirect_uri </p>
|
||||
</CenteredModal>
|
||||
{:else if !isGateway && !workspaceId}
|
||||
<p class="text-center text-sm text-primary mb-6">Error: missing workspace_id</p>
|
||||
<CenteredModal title="Authorization Request">
|
||||
<p class="text-center text-sm text-primary">Error: missing workspace_id</p>
|
||||
</CenteredModal>
|
||||
{:else}
|
||||
<CenteredModal title={success ? 'Authorization Approved' : 'Authorization Request'}>
|
||||
{#if success}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Renaming the page back drops it into the (logged) layout, where it hangs on
|
||||
// "Loading user..." with no type error and no other failing test — see the page header.
|
||||
const routeDir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
describe('mcp oauth consent route', () => {
|
||||
it('escapes the (logged) layout', () => {
|
||||
expect(readdirSync(routeDir)).toContain('+page@(root).svelte')
|
||||
})
|
||||
})
|
||||
@@ -191,6 +191,19 @@
|
||||
let guestAccessEnabled: boolean = $state(false)
|
||||
let guestUsage: GuestUsage | undefined = $state(undefined)
|
||||
let initialGuestAccessEnabled: boolean = $state(false)
|
||||
// A guest JWT is verified against one key: a PEM public key, or a JWKS URL. The
|
||||
// type picks which field is live; the other is cleared on save.
|
||||
let guestJwtKeyType = $state<'pem' | 'jwks'>('pem')
|
||||
let guestJwtPublicKey: string = $state('')
|
||||
let guestJwtJwksUrl: string = $state('')
|
||||
let initialGuestJwtPublicKey: string = $state('')
|
||||
let initialGuestJwtJwksUrl: string = $state('')
|
||||
// The pair actually saved: only the selected type's field, trimmed. The unselected
|
||||
// one is empty, so switching type and saving clears what was there.
|
||||
let effectiveGuestJwt = $derived({
|
||||
pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '',
|
||||
jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : ''
|
||||
})
|
||||
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
|
||||
let hasInstanceAiConfig = $state(false)
|
||||
@@ -526,11 +539,17 @@
|
||||
}
|
||||
|
||||
async function saveDefaultAppSettings(): Promise<void> {
|
||||
// Guests first: the only write of this card available on every plan, so a refused
|
||||
// Enterprise-only write after it cannot swallow it.
|
||||
// Guest access and the guest JWT key are the writes of this card available on every plan;
|
||||
// save them first so a refused Enterprise-only write after cannot swallow them.
|
||||
if (guestAccessEnabled !== initialGuestAccessEnabled) {
|
||||
await editGuestAccess()
|
||||
}
|
||||
if (
|
||||
effectiveGuestJwt.pem !== initialGuestJwtPublicKey ||
|
||||
effectiveGuestJwt.jwks !== initialGuestJwtJwksUrl
|
||||
) {
|
||||
await editGuestJwtKey()
|
||||
}
|
||||
if (workspaceDefaultAppPath !== initialWorkspaceDefaultAppPath) {
|
||||
await editWorkspaceDefaultApp()
|
||||
}
|
||||
@@ -539,6 +558,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function editGuestJwtKey(): Promise<void> {
|
||||
await WorkspaceService.editGuestJwtKey({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
public_key: effectiveGuestJwt.pem || undefined,
|
||||
jwks_url: effectiveGuestJwt.jwks || undefined
|
||||
}
|
||||
})
|
||||
initialGuestJwtPublicKey = effectiveGuestJwt.pem
|
||||
initialGuestJwtJwksUrl = effectiveGuestJwt.jwks
|
||||
sendUserToast('Guest JWT key updated')
|
||||
}
|
||||
|
||||
async function editGuestAccess(): Promise<void> {
|
||||
await WorkspaceService.editGuestAccess({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -647,6 +679,11 @@
|
||||
initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
|
||||
guestAccessEnabled = settings.guest_access_enabled ?? false
|
||||
initialGuestAccessEnabled = settings.guest_access_enabled ?? false
|
||||
guestJwtPublicKey = settings.guest_jwt_public_key ?? ''
|
||||
guestJwtJwksUrl = settings.guest_jwt_jwks_url ?? ''
|
||||
initialGuestJwtPublicKey = guestJwtPublicKey
|
||||
initialGuestJwtJwksUrl = guestJwtJwksUrl
|
||||
guestJwtKeyType = guestJwtJwksUrl ? 'jwks' : 'pem'
|
||||
WorkspaceService.getGuestUsage({ workspace: $workspaceStore! })
|
||||
.then((u) => (guestUsage = u))
|
||||
.catch(() => (guestUsage = undefined))
|
||||
@@ -1052,12 +1089,16 @@
|
||||
savedValue: {
|
||||
defaultAppPath: initialWorkspaceDefaultAppPath,
|
||||
publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute,
|
||||
guestAccessEnabled: initialGuestAccessEnabled
|
||||
guestAccessEnabled: initialGuestAccessEnabled,
|
||||
guestJwtPem: initialGuestJwtPublicKey,
|
||||
guestJwtJwks: initialGuestJwtJwksUrl
|
||||
},
|
||||
modifiedValue: {
|
||||
defaultAppPath: workspaceDefaultAppPath,
|
||||
publicAppRateLimitPerMinute: publicAppRateLimitPerMinute,
|
||||
guestAccessEnabled: guestAccessEnabled
|
||||
guestAccessEnabled: guestAccessEnabled,
|
||||
guestJwtPem: effectiveGuestJwt.pem,
|
||||
guestJwtJwks: effectiveGuestJwt.jwks
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1067,6 +1108,9 @@
|
||||
workspaceDefaultAppPath = initialWorkspaceDefaultAppPath
|
||||
publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute
|
||||
guestAccessEnabled = initialGuestAccessEnabled
|
||||
guestJwtPublicKey = initialGuestJwtPublicKey
|
||||
guestJwtJwksUrl = initialGuestJwtJwksUrl
|
||||
guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem'
|
||||
}
|
||||
|
||||
// Strip keys from extraArgs that are auto-managed by child components:
|
||||
@@ -2184,7 +2228,7 @@ export async function main(
|
||||
|
||||
<SettingCard
|
||||
label="Guests"
|
||||
description="Let anyone your identity provider authenticates open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
|
||||
description="Let anyone your identity provider authenticates, or a JWT your own backend signs (configured below), open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
|
||||
class="mt-6"
|
||||
>
|
||||
<Toggle
|
||||
@@ -2198,8 +2242,8 @@ export async function main(
|
||||
</span>
|
||||
{:else if guestUsage}
|
||||
<span class="text-hint text-2xs">
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across
|
||||
this instance in the last {guestUsage.window_days} days.
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
|
||||
instance in the last {guestUsage.window_days} days.
|
||||
{#if guestUsage.metered}
|
||||
Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0
|
||||
? ` (${guestUsage.guest_seats} now)`
|
||||
@@ -2210,6 +2254,54 @@ export async function main(
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Guest JWT verification key
|
||||
</div>
|
||||
<div class="text-2xs text-hint">
|
||||
A guest can also enter through a JWT your own backend mints and signs, with no
|
||||
identity-provider round-trip, for iframe embedding. The token must carry
|
||||
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
|
||||
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
|
||||
<code>app_path</code>. Accepted algorithms: RS256/384/512, PS256/384/512,
|
||||
ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM
|
||||
public key or a JWKS URL (which must be https). Point it at an issuer you
|
||||
control: any token that key signs carrying these claims is accepted, so a shared
|
||||
multi-tenant issuer is not a good fit.
|
||||
</div>
|
||||
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton {item} value="pem" label="PEM public key" />
|
||||
<ToggleButton {item} value="jwks" label="JWKS URL" />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if guestJwtKeyType === 'pem'}
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
class="font-mono text-xs"
|
||||
autosizeParams={{ minHeight: 128 }}
|
||||
inputProps={{
|
||||
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
|
||||
}}
|
||||
bind:value={guestJwtPublicKey}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
|
||||
}}
|
||||
bind:value={guestJwtJwksUrl}
|
||||
/>
|
||||
{/if}
|
||||
{#if !isCloudHosted()}
|
||||
<div class="text-2xs text-hint">
|
||||
Leave empty to fall back to the instance's configured JWT issuer (<code
|
||||
>JWT_EXT_JWKS_URL</code
|
||||
>), if one is set. Set a key here to trust a different issuer for this
|
||||
workspace.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingsFooter
|
||||
@@ -2218,7 +2310,10 @@ export async function main(
|
||||
onSave={saveDefaultAppSettings}
|
||||
onDiscard={discardDefaultAppSettingsChanges}
|
||||
saveLabel="Save app settings"
|
||||
disabled={!$enterpriseLicense && guestAccessEnabled === initialGuestAccessEnabled}
|
||||
disabled={!$enterpriseLicense &&
|
||||
guestAccessEnabled === initialGuestAccessEnabled &&
|
||||
effectiveGuestJwt.pem === initialGuestJwtPublicKey &&
|
||||
effectiveGuestJwt.jwks === initialGuestJwtJwksUrl}
|
||||
/>
|
||||
{:else if tab == 'native_triggers'}
|
||||
{#if $workspaceStore}
|
||||
|
||||
@@ -147,7 +147,10 @@
|
||||
} else {
|
||||
if (
|
||||
(!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) &&
|
||||
!page.url.pathname.startsWith('/oauth/mcp_authorize') &&
|
||||
// The MCP consent page carries its own workspace picker, so it is left to
|
||||
// run without one. Nothing sets `$userStore` on this branch, which is why
|
||||
// that page must stay outside the (logged) layout — see its `@(root)` name.
|
||||
!page.url.pathname.startsWith(`${base}/oauth/mcp_authorize`) &&
|
||||
// The hub import wizard asks for the destination itself, and may end in a
|
||||
// workspace that does not exist yet — bouncing it to the picker would
|
||||
// force the very choice it exists to make.
|
||||
|
||||
@@ -19,30 +19,46 @@
|
||||
let jwtError = $state(false)
|
||||
|
||||
function isJwt(t: string) {
|
||||
// simply check that the first part is a valid base64 encoded json
|
||||
// A JWT is three dot-separated base64url segments; check the header decodes to
|
||||
// JSON. `atob` wants standard base64, so normalise base64url first (a `kid` or a
|
||||
// signature routinely contains `-`/`_`), or a valid token is taken for a path.
|
||||
try {
|
||||
const parts = t.split('.')
|
||||
const header = atob(parts[0])
|
||||
JSON.parse(header)
|
||||
if (parts.length !== 3) return false
|
||||
const b64 = parts[0].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
|
||||
JSON.parse(atob(b64 + pad))
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseCustomPath(customPath: string): { path: string; jwt: string | undefined } {
|
||||
// The custom path may carry a trailing credential: an external JWT as its last
|
||||
// segment, or a guest JWT in a `guest.<jwt>` last segment (`<path>/guest.<jwt>`). The
|
||||
// `guest.` prefix keeps the two apart; `viewerUrl` uses `path` alone, so neither
|
||||
// reaches the opaque iframe.
|
||||
function parseCustomPath(customPath: string): {
|
||||
path: string
|
||||
jwt: string | undefined
|
||||
guestJwt: string | undefined
|
||||
} {
|
||||
const parts = customPath.split('/')
|
||||
if (parts.length > 1 && isJwt(parts[parts.length - 1])) {
|
||||
const last = parts[parts.length - 1]
|
||||
// A guest JWT rides the last segment prefixed `guest.`. The `.` means it can never
|
||||
// be a valid custom-path segment, so a real path ending in a `guest` segment
|
||||
// followed by an external JWT (`.../guest/<jwt>`) is read as before, not hijacked.
|
||||
if (last.startsWith('guest.') && isJwt(last.slice('guest.'.length))) {
|
||||
return {
|
||||
path: parts.slice(0, -1).join('/'),
|
||||
jwt: parts[parts.length - 1]
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
path: customPath,
|
||||
jwt: undefined
|
||||
jwt: undefined,
|
||||
guestJwt: last.slice('guest.'.length)
|
||||
}
|
||||
}
|
||||
if (parts.length > 1 && isJwt(last)) {
|
||||
return { path: parts.slice(0, -1).join('/'), jwt: last, guestJwt: undefined }
|
||||
}
|
||||
return { path: customPath, jwt: undefined, guestJwt: undefined }
|
||||
}
|
||||
|
||||
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
|
||||
@@ -102,7 +118,9 @@
|
||||
// Embedder side: validate access (main session cookie or shared JWT) and mint
|
||||
// a scoped embed token for the opaque iframe (WIN-2006).
|
||||
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
|
||||
if (parsedCustomPath.jwt) {
|
||||
if (parsedCustomPath.guestJwt) {
|
||||
OpenAPI.TOKEN = 'jwt_guest_' + parsedCustomPath.guestJwt
|
||||
} else if (parsedCustomPath.jwt) {
|
||||
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
@@ -27,12 +27,25 @@
|
||||
* offering an ordinary sign-in on a transient fault would provision an account. */
|
||||
let guestEntry: 'pending' | 'none' | 'guest' | 'error' = $state('pending')
|
||||
|
||||
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
|
||||
// The share link carries a trailing credential the embedder consumes: an external
|
||||
// JWT as `<secret>/<jwt>`, or a guest JWT as `<secret>/guest.<jwt>`. The `guest.`
|
||||
// prefix keeps the two apart with no parsing of the token, which the page cannot
|
||||
// verify anyway. Either way `viewerUrl` below uses `secret` alone, so no JWT
|
||||
// reaches the opaque iframe.
|
||||
function parseSecret(secret: string): {
|
||||
secret: string
|
||||
jwt: string | undefined
|
||||
guestJwt: string | undefined
|
||||
} {
|
||||
const parts = secret.split('/')
|
||||
return {
|
||||
secret: parts[0],
|
||||
jwt: parts[1]
|
||||
// The credential rides the segment after the secret: a guest JWT prefixed
|
||||
// `guest.`, or an external JWT bare. The `guest.` prefix glues the marker to the
|
||||
// token, so it can never be mistaken for a path or secret segment (which carry no
|
||||
// `.`), and a bare token keeps the established external-JWT interpretation.
|
||||
if (parts[1]?.startsWith('guest.')) {
|
||||
return { secret: parts[0], jwt: undefined, guestJwt: parts[1].slice('guest.'.length) }
|
||||
}
|
||||
return { secret: parts[0], jwt: parts[1], guestJwt: undefined }
|
||||
}
|
||||
|
||||
const parsedSecret = parseSecret(page.params.secret ?? '')
|
||||
@@ -52,7 +65,9 @@
|
||||
// Embedder side: validate access (using the main session cookie or the shared
|
||||
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
|
||||
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
|
||||
if (parsedSecret.jwt) {
|
||||
if (parsedSecret.guestJwt) {
|
||||
OpenAPI.TOKEN = 'jwt_guest_' + parsedSecret.guestJwt
|
||||
} else if (parsedSecret.jwt) {
|
||||
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.803.0"
|
||||
wmill = ">=1.804.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.3'
|
||||
|
||||
info:
|
||||
version: 1.803.0
|
||||
version: 1.804.0
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user