mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
Merge remote-tracking branch 'origin/main' into glm/onboarding-cloud
# Conflicts: # backend/ee-repo-ref.txt # frontend/src/lib/components/InstanceSettings.svelte # frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte
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"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE jwt_entry AND day > CURRENT_DATE - 30)::INT AS \"guest_jwt_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "since",
|
||||
"type_info": "Date"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "guest_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "guest_jwt_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "guest_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "guest_enabled_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "workspace_count!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1"
|
||||
}
|
||||
+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 guest_activity (email, workspace_id, day)\n VALUES ($1, $2, CURRENT_DATE)\n ON CONFLICT (email, workspace_id, day)\n DO UPDATE SET last_seen_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5"
|
||||
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM app WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "391139a04bd48319a5512e7859b63e81438c7483ac892b971fe8a20709555cc1"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007"
|
||||
"hash": "77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR label <> 'guest_session')\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726"
|
||||
"hash": "d17645b5001d7f8da1dc451c5d35ea3c9346271b8404863256071cfdf884036a"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "slack_command_script",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "teams_command_script",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "slack_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "slack_oauth_client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "slack_oauth_client_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "customer_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "webhook",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "ai_config",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "dbt_warehouses",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "ducklake",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "git_sync",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "default_scripts",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "git_app_installations",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "auto_invite",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "error_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "error_handler_fallback_to_instance_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"name": "guest_jwt_public_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "guest_jwt_jwks_url",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
|
||||
}
|
||||
+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
+199
-194
File diff suppressed because it is too large
Load Diff
+7
-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"
|
||||
|
||||
@@ -351,6 +351,8 @@ windmill-trigger-sqs.workspace = true
|
||||
windmill-trigger-gcp.workspace = true
|
||||
windmill-trigger-azure.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
tower-cookies.workspace = true
|
||||
windmill-api-users.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
windmill-api-client.workspace = true
|
||||
@@ -365,6 +367,7 @@ aws-config.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -597,6 +600,8 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
|
||||
const-str = "0.5"
|
||||
constant_time_eq = "0.3.1"
|
||||
rsa = "^0"
|
||||
spki = { version = "0.7", features = ["pem"] }
|
||||
pkcs1 = "0.7"
|
||||
aes-gcm = "0.10.3"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
|
||||
once_cell = "1.17.1"
|
||||
|
||||
@@ -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 @@
|
||||
747fbf5a869630d04f0cdabd79f42140ce60adeb
|
||||
ad367e756f3956fe335591b026352b01235e16fb
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS guest_activity;
|
||||
ALTER TABLE workspace_settings DROP COLUMN guest_access_enabled;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Guest app access: a workspace-level switch, off by default. An app whose policy says
|
||||
-- `execution_mode: guest` only admits guests where this is on, and the check runs where
|
||||
-- the guest session is minted -- an app definition carries its policy, so git-sync and
|
||||
-- the CLI push `guest` past every UI gate.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN guest_access_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- A guest leaves no `usr` or `password` row, which is what keeps them off every seat
|
||||
-- counter, so this is the only durable record that one was here: a row per guest,
|
||||
-- workspace and day, written when the session is minted.
|
||||
--
|
||||
-- Deliberately not the audit log. The seat scan is served by a partial index whose
|
||||
-- predicate names the login operations literally, and `audit_partitioned` is a
|
||||
-- partitioned table, where `CREATE INDEX CONCURRENTLY` is unsupported -- adding a
|
||||
-- guest operation to that predicate means a locking rebuild on the largest table an
|
||||
-- instance has. Guest logins still write `users.login_guest` for the audit trail;
|
||||
-- nothing counts them from there.
|
||||
CREATE TABLE guest_activity (
|
||||
email VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
day DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (email, workspace_id, day)
|
||||
);
|
||||
|
||||
-- The retention delete filters on day alone; the PK only reaches it through two
|
||||
-- other columns.
|
||||
CREATE INDEX idx_guest_activity_day ON guest_activity (day);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE guest_activity DROP COLUMN jwt_entry;
|
||||
ALTER TABLE workspace_settings
|
||||
DROP CONSTRAINT workspace_settings_guest_jwt_one_key,
|
||||
DROP COLUMN guest_jwt_public_key,
|
||||
DROP COLUMN guest_jwt_jwks_url;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- A second way in for a guest: a JWT minted by the embedding customer's own backend and
|
||||
-- verified against a key the workspace admin configured. One key shape per workspace,
|
||||
-- a PEM public key or a JWKS URL, never both: a token is verified against exactly one
|
||||
-- source, and two would make "which one refused it" undiagnosable.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN guest_jwt_public_key TEXT,
|
||||
ADD COLUMN guest_jwt_jwks_url TEXT,
|
||||
ADD CONSTRAINT workspace_settings_guest_jwt_one_key
|
||||
CHECK (guest_jwt_public_key IS NULL OR guest_jwt_jwks_url IS NULL);
|
||||
|
||||
-- Whether the guest came in on a JWT that day (as opposed to, or as well as, an
|
||||
-- identity-provider sign-in). The seat telemetry reports the two entries apart, since
|
||||
-- an app-only user routed through a guest JWT is one that `jwt_ext_` would have counted.
|
||||
ALTER TABLE guest_activity
|
||||
ADD COLUMN jwt_entry BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -29,6 +29,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -38,6 +42,11 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/drive"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/drive"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -47,6 +56,13 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/gmail.send"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.compose",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.labels"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -56,6 +72,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar.events"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.events.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -65,6 +87,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/forms"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/forms",
|
||||
"https://www.googleapis.com/auth/forms.body",
|
||||
"https://www.googleapis.com/auth/forms.body.readonly",
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -74,6 +102,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/cloud-platform.read-only"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -88,6 +120,15 @@
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit"
|
||||
],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/admin.directory.user",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.group",
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
|
||||
@@ -1937,6 +1937,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::error!("Error deleting old feature_usage rows: {e}");
|
||||
}
|
||||
|
||||
// Guest sign-ins, kept a month longer than the seat window they feed so a late
|
||||
// telemetry send still sees a whole month.
|
||||
if let Err(e) = sqlx::query!("DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60")
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting old guest_activity rows: {e}");
|
||||
}
|
||||
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
|
||||
)
|
||||
|
||||
@@ -110,6 +110,7 @@ folder_permission_history: id(bigint), workspace_id(char), folder_name(char), ch
|
||||
FK: (workspace_id, folder_name) -> folder(workspace_id, name)
|
||||
gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[])
|
||||
global_settings: name(char), value(jsonb), updated_at(ts)
|
||||
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz), jwt_entry(bool)
|
||||
group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char)
|
||||
@@ -222,7 +223,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The guest allowance: free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the
|
||||
//! trailing window. Past it, a hard-capped instance (Community, Pro) refuses a stranger
|
||||
//! and lets a returning guest back in; a metered one (Enterprise) admits everyone and
|
||||
//! counts seats. Its own binary: the plan is read from a process-wide key id that this
|
||||
//! test flips, which no test sharing the process could tolerate.
|
||||
//!
|
||||
//! Users from the `base` fixture:
|
||||
//! test-user (admin, token SECRET_TOKEN)
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::{FREE_GUESTS_PER_WINDOW, GUEST_WINDOW_DAYS};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
const APP_PATH: &str = "u/test-user/guest_app";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
/// Community and Pro are capped, Enterprise is metered. Only a build with both
|
||||
/// `private` (the key id) and `enterprise` (the plan read) can meter; every other build
|
||||
/// is capped whatever this says.
|
||||
fn set_plan(pro: bool) {
|
||||
#[cfg(feature = "private")]
|
||||
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(
|
||||
if pro { "test_pro" } else { "" }.to_string(),
|
||||
));
|
||||
let _ = pro;
|
||||
}
|
||||
|
||||
async fn mint(db: &Pool<Postgres>, email: &str) -> windmill_common::error::Result<String> {
|
||||
let mut tx = db.begin().await.unwrap();
|
||||
let minted = windmill_api_users::users::create_guest_session_token(
|
||||
email,
|
||||
"test-workspace",
|
||||
APP_PATH,
|
||||
&mut tx,
|
||||
tower_cookies::Cookies::default(),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await.unwrap();
|
||||
minted
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn the_allowance_caps_strangers_and_meters_an_enterprise_plan(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "guest", "triggerables_v2": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
// The whole allowance, used yesterday: still in the window, and a day the mint
|
||||
// does not write, so a row dated today can only be the mint's own.
|
||||
sqlx::query(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
SELECT 'g' || i || '@example.com', 'test-workspace', CURRENT_DATE - 1
|
||||
FROM generate_series(1, $1) AS i",
|
||||
)
|
||||
.bind(FREE_GUESTS_PER_WINDOW)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
set_plan(true);
|
||||
let refused = mint(&db, "stranger@example.com").await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&refused, windmill_common::error::Error::PermissionDenied(m)
|
||||
if m.contains(&format!("limit of {FREE_GUESTS_PER_WINDOW} guests over {GUEST_WINDOW_DAYS} days"))),
|
||||
"a stranger past the allowance is refused with the message the visitor reads: {refused:?}"
|
||||
);
|
||||
mint(&db, "g1@example.com")
|
||||
.await
|
||||
.expect("a guest already in the window is let back in");
|
||||
let recorded: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guest_activity
|
||||
WHERE email = 'g1@example.com' AND workspace_id = 'test-workspace'
|
||||
AND day = CURRENT_DATE)",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
recorded,
|
||||
"the mint writes today's guest_activity row, the allowance's unit"
|
||||
);
|
||||
|
||||
let list: serde_json::Value = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/users/guests?per_page=5"
|
||||
)),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(list["usage"]["guest_count"], FREE_GUESTS_PER_WINDOW);
|
||||
assert_eq!(list["usage"]["metered"], false);
|
||||
assert_eq!(list["usage"]["guest_seats"], 0);
|
||||
assert_eq!(list["guests"].as_array().map(Vec::len), Some(5));
|
||||
assert_eq!(list["guests"][0]["workspaces"], json!(["test-workspace"]));
|
||||
let usage: serde_json::Value = authed(
|
||||
client().get(format!("{ws}/workspaces/guest_usage")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW);
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
{
|
||||
set_plan(false);
|
||||
mint(&db, "stranger@example.com")
|
||||
.await
|
||||
.expect("a metered plan admits past the allowance");
|
||||
let usage: serde_json::Value = authed(
|
||||
client().get(format!("{ws}/workspaces/guest_usage")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW + 1);
|
||||
assert_eq!(usage["metered"], true);
|
||||
assert_eq!(usage["billable_guests"], 1);
|
||||
assert_eq!(
|
||||
usage["guest_seats"], 1,
|
||||
"one guest past the allowance is a whole seat"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// These pin language-independent behaviour, and Python is the cheapest runtime that still
|
||||
// generates a real lock out of relative imports, so the whole file needs that feature.
|
||||
#![cfg(feature = "python")]
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio_stream::StreamExt;
|
||||
use windmill_api_client::types::NewScript;
|
||||
@@ -6,21 +10,27 @@ use windmill_test_utils::*;
|
||||
|
||||
const W: &str = "test-workspace";
|
||||
|
||||
const A: &str = r#"export async function main() { return "a" }"#;
|
||||
const A_COMMENTED: &str = r#"// same dependencies, different content
|
||||
export async function main() { return "a" }"#;
|
||||
const A_WITH_LODASH: &str = r#"import _ from "lodash@4.17.21";
|
||||
export async function main() { return _.trim(" a ") }"#;
|
||||
const B: &str = r#"import { main as a } from "/f/rel/a.ts";
|
||||
export async function main() { return "b" + (await a()) }"#;
|
||||
const C: &str = r#"import { main as b } from "/f/rel/b.ts";
|
||||
export async function main() { return "c" + (await b()) }"#;
|
||||
/// Budget for one wait below, counted completions and drain together. Even against a cold cache
|
||||
/// these settle in a few seconds, so it only bounds a step that is stuck, and it stays under the
|
||||
/// 60s cap `in_test_worker` puts on the whole body so the panic names what was being waited on
|
||||
/// rather than surfacing as a worker timeout.
|
||||
const WAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
fn bun_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
|
||||
/// How often the waits below re-check. The drain reads the queue once per completion it waits
|
||||
/// this long for, so it doubles as the floor on one turn of that loop.
|
||||
const POLL: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
|
||||
const A: &str = "def main():\n return 'a'\n";
|
||||
const A_COMMENTED: &str = "# same dependencies, different content\ndef main():\n return 'a'\n";
|
||||
const A_WITH_TINY: &str = "import tiny\n\ndef main():\n return 'a'\n";
|
||||
const B: &str = "from f.rel.a import main as a\n\ndef main():\n return 'b' + a()\n";
|
||||
const C: &str = "from f.rel.b import main as b\n\ndef main():\n return 'c' + b()\n";
|
||||
|
||||
fn py_script(path: &str, content: &str, parent_hash: Option<String>) -> NewScript {
|
||||
NewScript {
|
||||
draft_only: None,
|
||||
content: content.into(),
|
||||
language: windmill_api_client::types::ScriptLang::Bun,
|
||||
language: windmill_api_client::types::ScriptLang::Python3,
|
||||
lock: None,
|
||||
parent_hash,
|
||||
path: path.into(),
|
||||
@@ -96,17 +106,33 @@ async fn dependency_jobs_since(
|
||||
}
|
||||
|
||||
async fn wait_for_jobs(
|
||||
db: &Pool<Postgres>,
|
||||
completed: &mut (impl futures::Stream<Item = uuid::Uuid> + Unpin),
|
||||
count: usize,
|
||||
) {
|
||||
for _ in 0..count {
|
||||
completed.next().await;
|
||||
let deadline = tokio::time::Instant::now() + WAIT_BUDGET;
|
||||
for i in 0..count {
|
||||
tokio::time::timeout_at(deadline, completed.next())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("only {i} of {count} jobs completed"));
|
||||
}
|
||||
// Then let anything else that was queued run out, so a job the assertions say must not
|
||||
// exist would have shown up here.
|
||||
while let Ok(Some(_)) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), completed.next()).await
|
||||
{}
|
||||
// exist would have shown up here. A dependency job queues its fan-out before it completes,
|
||||
// so an empty queue is a fixpoint rather than a lull.
|
||||
loop {
|
||||
while let Ok(Some(_)) = tokio::time::timeout(POLL, completed.next()).await {}
|
||||
let queued: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job_queue")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap();
|
||||
if queued == 0 {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"the queue never emptied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A redeploy of an imported script whose dependencies did not move relocks its importer,
|
||||
@@ -128,10 +154,10 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
// importer whose edges are recorded is what a later relock of it can skip on.
|
||||
for (path, content) in [("f/rel/a", A), ("f/rel/b", B), ("f/rel/c", C)] {
|
||||
client
|
||||
.create_script(W, &bun_script(path, content, None))
|
||||
.create_script(W, &py_script(path, content, None))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
}
|
||||
let b_before = versions(&db, "f/rel/b").await;
|
||||
let c_before = versions(&db, "f/rel/c").await;
|
||||
@@ -144,11 +170,11 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
&py_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 2).await;
|
||||
wait_for_jobs(&db, &mut completed, 2).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
@@ -181,11 +207,11 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_WITH_LODASH, Some(format!("{a_hash:016x}"))),
|
||||
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 3).await;
|
||||
wait_for_jobs(&db, &mut completed, 3).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
@@ -203,7 +229,7 @@ async fn relative_import_relock_deploys_only_when_the_lock_changed(
|
||||
);
|
||||
assert!(vs[0].created_at < vs[1].created_at, "{path}: lineage order");
|
||||
assert!(
|
||||
vs[1].lock.as_deref().unwrap_or("").contains("lodash"),
|
||||
vs[1].lock.as_deref().unwrap_or("").contains("tiny"),
|
||||
"{path}: the new version carries the new lock: {:?}",
|
||||
vs[1].lock
|
||||
);
|
||||
@@ -233,10 +259,10 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
async {
|
||||
for (path, content) in [("f/rel/a", A), ("f/rel/b", B)] {
|
||||
client
|
||||
.create_script(W, &bun_script(path, content, None))
|
||||
.create_script(W, &py_script(path, content, None))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
}
|
||||
|
||||
// A deploy of b that holds its head's row lock for as long as this transaction lives.
|
||||
@@ -251,14 +277,15 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
&bun_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
&py_script("f/rel/a", A_COMMENTED, Some(format!("{a_hash:016x}"))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// b's relock skips generation and reaches its commit, where it waits on the lock.
|
||||
let deadline = std::time::Instant::now() + WAIT_BUDGET;
|
||||
let mut waiting = false;
|
||||
for _ in 0..300 {
|
||||
while !waiting && std::time::Instant::now() < deadline {
|
||||
waiting = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND wait_event_type = 'Lock'
|
||||
@@ -267,10 +294,9 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
if waiting {
|
||||
break;
|
||||
if !waiting {
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(waiting, "b's relock never reached the row lock");
|
||||
|
||||
@@ -284,7 +310,7 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
deploy.commit().await.unwrap();
|
||||
|
||||
// a's own job, the relock that waited, and the relock it queued for the successor.
|
||||
wait_for_jobs(&mut completed, 3).await;
|
||||
wait_for_jobs(&db, &mut completed, 3).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
@@ -324,7 +350,6 @@ async fn relock_waiting_on_a_deploy_requeues_for_its_successor(
|
||||
|
||||
/// A multi-file importer: on a skipped relock each module gets its own last lock back, not the
|
||||
/// parent script's, so an import's content-only redeploy leaves the importer alone as well.
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
|
||||
@@ -332,8 +357,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
|
||||
let py = |path: &str, content: &str, parent_hash: Option<String>, with_module: bool| {
|
||||
let mut ns = bun_script(path, content, parent_hash);
|
||||
ns.language = windmill_api_client::types::ScriptLang::Python3;
|
||||
let mut ns = py_script(path, content, parent_hash);
|
||||
if with_module {
|
||||
ns.modules = Some(std::collections::HashMap::from([(
|
||||
"helper.py".to_string(),
|
||||
@@ -363,7 +387,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
.create_script(W, &py("f/rel/pa", "def main():\n return 'a'\n", None, false))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
client
|
||||
.create_script(
|
||||
W,
|
||||
@@ -376,7 +400,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 1).await;
|
||||
wait_for_jobs(&db, &mut completed, 1).await;
|
||||
let lock_before = module_lock(&db).await;
|
||||
assert!(lock_before.is_some(), "the module got a lock of its own on deploy");
|
||||
|
||||
@@ -394,7 +418,7 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_jobs(&mut completed, 2).await;
|
||||
wait_for_jobs(&db, &mut completed, 2).await;
|
||||
|
||||
let jobs = dependency_jobs_since(&db, since).await;
|
||||
let paths: Vec<&str> = jobs.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
|
||||
@@ -178,6 +178,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,12 +138,28 @@ impl AuthCache {
|
||||
w_id: Option<String>,
|
||||
token: &str,
|
||||
) -> Option<OptJobAuthed> {
|
||||
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id, token).await?;
|
||||
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id.clone(), token).await?;
|
||||
// Single source of truth: mirror the resolved job_id onto the authed so
|
||||
// every consumer (require_super_admin, ...) sees that this identity came
|
||||
// from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed
|
||||
// predates this field.
|
||||
opt_job_authed.authed.job_id = opt_job_authed.job_id;
|
||||
// The workspace's guest switch is enforced here, once, for every guest request
|
||||
// — not per handler, where each guest-reachable route would have to remember
|
||||
// it. Uncached, so turning guests off takes effect on the next request of every
|
||||
// guest session and every token derived from one.
|
||||
if crate::scopes::has_guest_sentinel(opt_job_authed.authed.scopes.as_deref()) {
|
||||
let Some(w_id) = w_id else { return None };
|
||||
let email = &opt_job_authed.authed.email;
|
||||
match windmill_common::workspaces::guest_session_stands(&self.db, &w_id, email).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest session check failed for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(opt_job_authed)
|
||||
}
|
||||
|
||||
@@ -159,6 +175,18 @@ impl AuthCache {
|
||||
if is_no_auth() {
|
||||
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
|
||||
}
|
||||
// Reject an oversized guest bearer before the cache key is built from it: the key
|
||||
// copies and hashes the whole token, so the cap should bound that work too. Log it
|
||||
// like the other guest refusals, since get_opt_job_authed turns None into a bare 401.
|
||||
if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX)
|
||||
&& token.len() > windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
|
||||
{
|
||||
tracing::error!(
|
||||
"guest JWT refused: bearer is longer than {} bytes",
|
||||
windmill_common::guest_jwt::MAX_GUEST_JWT_LEN
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let key = (
|
||||
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
|
||||
token.to_string(),
|
||||
@@ -200,6 +228,111 @@ impl AuthCache {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ if token.starts_with(windmill_common::guest_jwt::BEARER_PREFIX) => {
|
||||
// A workspace-less route never accepts a guest JWT: the identity is
|
||||
// pinned to the workspace its claim names, like a DB guest session.
|
||||
let Some(w_id) = w_id.as_deref() else {
|
||||
return None;
|
||||
};
|
||||
// Strip exactly one prefix: `trim_start_matches` would strip repeated prefixes,
|
||||
// so `jwt_guest_jwt_guest_<jwt>` would reduce to a valid token that verifies and
|
||||
// is then cached under the full, non-canonical bearer key.
|
||||
let jwt = token
|
||||
.strip_prefix(windmill_common::guest_jwt::BEARER_PREFIX)
|
||||
.unwrap_or(token);
|
||||
let claims =
|
||||
match windmill_common::guest_jwt::verify_for_workspace(&self.db, w_id, jwt)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT auth error for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The workspace switch, the instance switch and the app being in guest
|
||||
// mode, in one answer (guest_app_admits). The door re-reads the switches
|
||||
// and the no-account rule per request through the sentinel below
|
||||
// (guest_session_stands), so turning any of them off stops a cached JWT
|
||||
// session on its next call.
|
||||
match windmill_common::workspaces::guest_app_admits(
|
||||
&self.db,
|
||||
w_id,
|
||||
&claims.app_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT admit check failed for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Resolve on the lowercased email: accounts are stored lowercased, so a
|
||||
// mixed-case claim would otherwise slip past the no-account gate and
|
||||
// resolve an account holder to a guest, and split the activity rows the
|
||||
// seat count reads.
|
||||
let email = claims.email.to_lowercase();
|
||||
// A guest is someone with no account at all; an account holder is refused,
|
||||
// never downgraded (the same rule as the signed-in guest mint).
|
||||
match windmill_common::users::has_any_account(&self.db, &email).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => return None,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT account check failed: {e:#}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// The instance allowance, checked and recorded transactionally. A stranger
|
||||
// past the cap on a capped instance is refused here; a returning guest
|
||||
// always passes. Recording an account holder is avoided by the check above.
|
||||
if !admit_and_record_guest_jwt(&self.db, w_id, &email, &claims.app_path).await {
|
||||
return None;
|
||||
}
|
||||
// guest_session_scopes already carries the sentinel, and it is the whole
|
||||
// grant; a JWT has no label, so the sentinel is what governs it. It also
|
||||
// re-checks the path holds no scope metacharacter (verify already did).
|
||||
let scopes = match crate::scopes::guest_session_scopes(&claims.app_path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT app_path cannot be scoped for {w_id}: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The JWT's own expiry caps a token minted from this session. The auth
|
||||
// cache entry itself is capped far shorter (GUEST_JWT_CACHE_TTL) so a
|
||||
// rotated or cleared key stops the session on re-verification, within
|
||||
// minutes, rather than only at exp (up to 24h away).
|
||||
let credential_expiry =
|
||||
chrono::Utc.timestamp_nanos(claims.exp as i64 * 1_000_000_000);
|
||||
let cache_expiry = credential_expiry.min(chrono::Utc::now() + GUEST_JWT_CACHE_TTL);
|
||||
let authed = ApiAuthed {
|
||||
username: email.clone(),
|
||||
email,
|
||||
is_admin: false,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override: None,
|
||||
username_override_is_token_label: false,
|
||||
is_session_token: false,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: Some(credential_expiry),
|
||||
};
|
||||
AUTH_CACHE.insert(
|
||||
key,
|
||||
ExpiringAuthCache {
|
||||
authed: authed.clone(),
|
||||
expiry: cache_expiry,
|
||||
job_id: None,
|
||||
},
|
||||
);
|
||||
Some(OptJobAuthed { authed, job_id: None })
|
||||
}
|
||||
_ if token.starts_with("jwt_") => {
|
||||
let jwt_token = token.trim_start_matches("jwt_");
|
||||
|
||||
@@ -233,6 +366,7 @@ impl AuthCache {
|
||||
token_prefix: claims.audit_span,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
};
|
||||
// Fail closed: a `job_id` claim that does not parse must reject
|
||||
// the token rather than resolve to `None`, which would clear the
|
||||
@@ -347,6 +481,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@@ -400,6 +535,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@@ -427,6 +563,8 @@ impl AuthCache {
|
||||
}
|
||||
(_, Some(email), super_admin, scopes, label, read_only) => {
|
||||
let is_session_token = is_session_label(label.as_deref());
|
||||
let is_guest_session =
|
||||
windmill_common::auth::is_guest_session_label(label.as_deref());
|
||||
let (username_override, username_override_is_token_label) =
|
||||
username_override_from_label(label);
|
||||
if w_id.is_some() {
|
||||
@@ -476,6 +614,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
None if super_admin => {
|
||||
@@ -500,6 +639,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
@@ -509,6 +649,37 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A guest session: IdP-authenticated, member of
|
||||
// nothing. No `usr` lookup, groups or folders, so
|
||||
// every ACL denies it and the token's scopes are
|
||||
// its whole grant. After the superadmin arm, so
|
||||
// that token is never demoted into this one.
|
||||
None if is_guest_session => {
|
||||
// The server-minted label is the grant, never
|
||||
// the `guest` scope (a user-minted token's
|
||||
// scopes are whatever the caller typed); the
|
||||
// sentinel is pinned on here so every guest
|
||||
// control downstream sees a guest regardless.
|
||||
let scopes = Some(crate::scopes::with_guest_sentinel(
|
||||
scopes.unwrap_or_default(),
|
||||
));
|
||||
Some(ApiAuthed {
|
||||
username: email.clone(),
|
||||
email,
|
||||
is_admin: false,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override,
|
||||
username_override_is_token_label,
|
||||
is_session_token,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
@@ -526,6 +697,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -564,6 +736,7 @@ impl AuthCache {
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
};
|
||||
Some(OptJobAuthed { authed, job_id: None })
|
||||
} else {
|
||||
@@ -574,6 +747,127 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a guest JWT resolves from the auth cache before the arm re-runs (and
|
||||
/// re-reads the key). A guest JWT is not revocable except by the workspace switch or
|
||||
/// by rotating the key, so the entry must be short enough that a rotated key bites
|
||||
/// soon, unlike a normal token whose row can be deleted. Also what makes the
|
||||
/// day-keyed activity dedupe below reachable across a midnight.
|
||||
const GUEST_JWT_CACHE_TTL: chrono::Duration = chrono::Duration::minutes(5);
|
||||
|
||||
/// A refused JWT (a stranger past the allowance) is remembered this long so a replayed
|
||||
/// bearer does not take the instance-wide allowance advisory lock on every request.
|
||||
/// Short, so a stranger admitted once the window frees is re-checked soon.
|
||||
const GUEST_JWT_REFUSED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// One `guest_activity` upsert and one `users.login_guest` audit per email,
|
||||
// workspace and day: the arm re-runs every GUEST_JWT_CACHE_TTL, and neither the
|
||||
// seat scan nor the audit trail wants a write each time. LRU-bounded; the day is in
|
||||
// the key, so a new day writes again.
|
||||
static ref GUEST_JWT_ACTIVITY_CACHE: Cache<String, ()> = Cache::new(2000);
|
||||
static ref GUEST_JWT_REFUSED_CACHE: Cache<String, std::time::Instant> = Cache::new(2000);
|
||||
}
|
||||
|
||||
/// Admit a JWT guest against the instance allowance and record today's activity, in one
|
||||
/// transaction so the advisory lock in `guest_admission` spans the count check and the
|
||||
/// row that changes it. Returns false when the allowance refuses the email or on a DB
|
||||
/// error, both of which deny the guest. Cached per email, workspace and day: a bearer
|
||||
/// replayed every request runs this at most once a day, and a refused one is remembered
|
||||
/// briefly so it does not re-take the allowance lock. `email` is already lowercased.
|
||||
async fn admit_and_record_guest_jwt(db: &DB, w_id: &str, email: &str, app_path: &str) -> bool {
|
||||
let cache_key = format!("{email}|{w_id}|{}", chrono::Utc::now().date_naive());
|
||||
if GUEST_JWT_ACTIVITY_CACHE.get(&cache_key).is_some() {
|
||||
return true;
|
||||
}
|
||||
if GUEST_JWT_REFUSED_CACHE
|
||||
.get(&cache_key)
|
||||
.is_some_and(|at| at.elapsed() < GUEST_JWT_REFUSED_TTL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut tx = match db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT tx begin failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// The allowance and the row that changes it, in one transaction: guest_admission
|
||||
// takes a transaction-scoped advisory lock, so the count check and the insert cannot
|
||||
// race two strangers past the cap. Only a real allowance refusal is negative-cached;
|
||||
// a transient DB error denies this request but must not lock the email out for 30s.
|
||||
match windmill_common::workspaces::guest_admission(&mut *tx, email).await {
|
||||
Ok(()) => {}
|
||||
Err(e @ windmill_common::error::Error::PermissionDenied(_)) => {
|
||||
// The guest hits a bare 401 (the reason must not leak to an unauthenticated caller);
|
||||
// warn so an admin sees the cap in logs, since it is the actionable signal here.
|
||||
tracing::warn!("guest JWT refused (guest allowance) for {w_id}: {e:#}");
|
||||
GUEST_JWT_REFUSED_CACHE.insert(cache_key, std::time::Instant::now());
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("guest JWT allowance check failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The conditional `WHERE NOT jwt_entry` flips the flag only on its false-to-true
|
||||
// transition, so the upsert returns a row exactly once per email per day: on the
|
||||
// fresh insert, or on the first JWT after an identity-provider sign-in created
|
||||
// today's row with `jwt_entry = false`. The audit is gated on that, decided
|
||||
// atomically by the conflicting tuple, so concurrent first requests (a metered
|
||||
// instance takes no advisory lock) audit at most once.
|
||||
let first_jwt = sqlx::query_scalar!(
|
||||
r#"INSERT INTO guest_activity (email, workspace_id, day, jwt_entry)
|
||||
VALUES ($1, $2, CURRENT_DATE, true)
|
||||
ON CONFLICT (email, workspace_id, day)
|
||||
DO UPDATE SET jwt_entry = true, last_seen_at = now()
|
||||
WHERE NOT guest_activity.jwt_entry
|
||||
RETURNING 1 AS "audited!""#,
|
||||
email,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
let first_jwt = match first_jwt {
|
||||
Ok(v) => v.is_some(),
|
||||
Err(e) => {
|
||||
tracing::error!("recording guest JWT activity for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = tx.commit().await {
|
||||
tracing::error!("guest JWT tx commit failed for {w_id}: {e:#}");
|
||||
return false;
|
||||
}
|
||||
GUEST_JWT_ACTIVITY_CACHE.insert(cache_key, ());
|
||||
// Audit last, best-effort, on its own connection: the EE writer swallows an
|
||||
// `audit_partitioned` failure but that failing statement still aborts the
|
||||
// transaction it runs in, so auditing before the commit would let the whole
|
||||
// activity row roll back while this returned success, admitting an uncounted guest.
|
||||
if first_jwt {
|
||||
let author = windmill_common::audit::AuditAuthor {
|
||||
email: email.to_string(),
|
||||
username: email.to_string(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
};
|
||||
if let Err(e) = windmill_audit::audit_oss::audit_log(
|
||||
db,
|
||||
&author,
|
||||
"users.login_guest",
|
||||
windmill_audit::ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
Some([("entry", "jwt")].into()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("auditing guest JWT login for {w_id}: {e:#}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
@@ -774,6 +1068,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,10 +1229,17 @@ pub(crate) fn username_override_from_label(label: Option<String>) -> (Option<Str
|
||||
{
|
||||
(Some(label), true)
|
||||
}
|
||||
Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => (
|
||||
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
|
||||
true,
|
||||
),
|
||||
Some(label)
|
||||
if label != "ephemeral-script"
|
||||
&& label != "session"
|
||||
&& label != windmill_common::auth::GUEST_SESSION_LABEL
|
||||
&& !label.is_empty() =>
|
||||
{
|
||||
(
|
||||
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
_ => (None, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -500,6 +500,24 @@ pub fn check_route_access(
|
||||
}
|
||||
}
|
||||
|
||||
// A guest session carries the same broad read scopes as an embed token and for
|
||||
// the same handful of routes, so it gets the same default-deny.
|
||||
if has_guest_sentinel(Some(token_scopes)) {
|
||||
if let Some(suffix) = route_suffix.as_deref() {
|
||||
if guest_route_denied(required_domain, suffix) {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"a guest session cannot access {route_path}"
|
||||
)));
|
||||
}
|
||||
// Same rationale as the embed branch: re-running a component supersedes
|
||||
// its in-flight run, and `cancel_job_api` confines this to the caller's
|
||||
// own jobs.
|
||||
if suffix.starts_with("jobs_u/queue/cancel/") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each declared scope must grant what its prompt said and no more:
|
||||
// `jobs:run` only deployed runnables, `users:read` only the viewer's identity.
|
||||
if has_raw_app_sdk_sentinel(Some(token_scopes)) {
|
||||
@@ -753,6 +771,55 @@ pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
|
||||
}
|
||||
|
||||
/// Sentinel in a guest session token: someone the identity provider authenticated
|
||||
/// who is a member of no workspace. Grants nothing itself — it only confines the
|
||||
/// session to the app surface, the same way `app_embed` does. What makes a session a
|
||||
/// guest at all is the server-minted label
|
||||
/// [`windmill_common::auth::GUEST_SESSION_LABEL`]; a forged sentinel here can only
|
||||
/// narrow its own token.
|
||||
pub const GUEST_SENTINEL: &str = "guest";
|
||||
|
||||
/// True if a token is a guest session, whose scopes are its entire grant: it has no ACL
|
||||
/// of its own, so every ACL check denies it unaided.
|
||||
pub fn has_guest_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == GUEST_SENTINEL))
|
||||
}
|
||||
|
||||
/// `scopes` with the guest sentinel present exactly once.
|
||||
pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
|
||||
if !scopes.iter().any(|x| x == GUEST_SENTINEL) {
|
||||
scopes.push(GUEST_SENTINEL.to_string());
|
||||
}
|
||||
scopes
|
||||
}
|
||||
|
||||
/// Scopes a guest session carries. The broad-looking reads are narrowed to a route
|
||||
/// allowlist by the sentinel (`guest_route_denied`), plus the two path-scoped app
|
||||
/// grants. A guest has no `usr` row, so this list is the whole of what it can do. The
|
||||
/// single source both the mint (a signed-in guest) and the JWT auth arm build from.
|
||||
///
|
||||
/// The sentinel here only narrows. A signed-in guest is made one by the server-minted
|
||||
/// label; a JWT guest has no label, so for it the sentinel is what governs.
|
||||
pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Vec<String>> {
|
||||
// The path is spliced into a scope, whose grammar reserves `:`, `,`, `*` and a leading
|
||||
// `/`; app paths may otherwise carry spaces and `@`, so guard only those reserved chars.
|
||||
if !windmill_common::auth::is_scope_literal_path(app_path) {
|
||||
return Err(windmill_common::error::Error::BadRequest(format!(
|
||||
"app path {app_path} is empty or cannot be scoped: `:`, `,` and `*` are reserved \
|
||||
in scopes, and a leading `/` never matches a route"
|
||||
)));
|
||||
}
|
||||
Ok(vec![
|
||||
GUEST_SENTINEL.to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
"users:read".to_string(),
|
||||
"folders:read".to_string(),
|
||||
format!("apps:read:{app_path}"),
|
||||
format!("apps:run:{app_path}"),
|
||||
])
|
||||
}
|
||||
|
||||
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
|
||||
/// to narrow the declared scopes to what the viewer's prompt promised.
|
||||
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
|
||||
@@ -815,6 +882,19 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool {
|
||||
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
|
||||
}
|
||||
|
||||
/// Routes a guest session is denied: the app-embed allowlist, plus the embed-token
|
||||
/// mint. A guest session is the *embedder* — the viewer's own browser rendering the
|
||||
/// app page — not the app's own JS, and the page mints the iframe's token from it.
|
||||
///
|
||||
/// Everything else stays default-denied, so a guest reaches the app it was let in
|
||||
/// for and nothing around it.
|
||||
fn guest_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
|
||||
if domain == ScopeDomain::Apps && suffix.starts_with("apps_u/embed_token") {
|
||||
return false;
|
||||
}
|
||||
app_embed_route_denied(domain, suffix)
|
||||
}
|
||||
|
||||
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
|
||||
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
|
||||
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
|
||||
|
||||
@@ -15,6 +15,12 @@ use windmill_test_utils::*;
|
||||
|
||||
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
|
||||
|
||||
/// A bun lock the executor accepts without installing anything: no dependencies
|
||||
/// in the `package.json` half, `<empty>` for the `bun.lock` half. The empty
|
||||
/// string is not a substitute: a lock carrying no `//bun.lock` separator is
|
||||
/// rejected at run time.
|
||||
const EMPTY_BUN_LOCK: &str = "{}\n//bun.lock\n<empty>";
|
||||
|
||||
/// Echoes the two halves of the event separately, so the assertions can tell
|
||||
/// which one a value arrived in.
|
||||
const PREPROCESSOR_SCRIPT: &str = r#"
|
||||
@@ -84,7 +90,7 @@ async fn test_mcp_preprocessor_receives_the_callers_headers(
|
||||
"description": "",
|
||||
"content": PREPROCESSOR_SCRIPT,
|
||||
"language": "bun",
|
||||
"lock": "",
|
||||
"lock": EMPTY_BUN_LOCK,
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
@@ -101,13 +107,14 @@ async fn test_mcp_preprocessor_receives_the_callers_headers(
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
// A script counts as deployed once it has a lock, which normally arrives from
|
||||
// a dependency job. Planting an empty one keeps the test to the path under
|
||||
// test instead of a bun resolution whose timing it does not control.
|
||||
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
|
||||
.bind(SCRIPT_PATH)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// A supplied lock queues no dependency job, so the version is deployed (hence
|
||||
// listable and runnable) as soon as the create returns.
|
||||
let queued: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM v2_job_queue WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(queued, 0, "the supplied lock must queue no dependency job");
|
||||
|
||||
let tools = mcp_post(
|
||||
port,
|
||||
|
||||
@@ -63,6 +63,7 @@ fn test_authed() -> ApiAuthed {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -657,15 +657,17 @@ async fn logout(
|
||||
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
|
||||
|
||||
let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT {
|
||||
sqlx::query_scalar!(
|
||||
// A guest's browser session is a session too: this is its one user-driven revocation.
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"WITH email_lookup AS (
|
||||
SELECT email FROM token WHERE token_hash = $1
|
||||
)
|
||||
DELETE FROM token
|
||||
WHERE email = (SELECT email FROM email_lookup) AND label = 'session'
|
||||
WHERE email = (SELECT email FROM email_lookup)
|
||||
AND label IN ('session', 'guest_session')
|
||||
RETURNING email",
|
||||
t_hash
|
||||
)
|
||||
.bind(&t_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
@@ -745,7 +747,30 @@ async fn whoami(
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
) -> JsonResult<UserInfo> {
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
|
||||
// A guest would otherwise fall through to the non-member branch below and be
|
||||
// handed a `superadmin` role. Answer it here, as the operator-shaped identity it is.
|
||||
if is_guest {
|
||||
return Ok(Json(UserInfo {
|
||||
workspace_id: w_id,
|
||||
email,
|
||||
username,
|
||||
name: None,
|
||||
is_admin: false,
|
||||
is_super_admin: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
groups: vec![],
|
||||
operator: true,
|
||||
disabled: false,
|
||||
role: Some("guest".to_string()),
|
||||
folders_read: vec![],
|
||||
folders: vec![],
|
||||
folders_owners: vec![],
|
||||
is_service_account: false,
|
||||
non_member: true,
|
||||
}));
|
||||
}
|
||||
let user = get_user(&w_id, &username, &db).await?;
|
||||
// Only treat the row as "this user is a member" when its email matches; the
|
||||
// derived username is instance-unique so a match on a different email should
|
||||
@@ -2882,7 +2907,12 @@ pub async fn create_session_token<'c>(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.clone());
|
||||
set_session_cookie(&cookies, &token, *MAX_SESSION_VALIDITY_SECONDS);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn set_session_cookie(cookies: &Cookies, token: &str, validity_seconds: i64) {
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.to_string());
|
||||
cookie.set_secure(IS_SECURE.load(std::sync::atomic::Ordering::Relaxed));
|
||||
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
@@ -2892,9 +2922,116 @@ pub async fn create_session_token<'c>(
|
||||
}
|
||||
|
||||
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
|
||||
expire += time::Duration::seconds(*MAX_SESSION_VALIDITY_SECONDS);
|
||||
expire += time::Duration::seconds(validity_seconds);
|
||||
cookie.set_expires(expire);
|
||||
cookies.add(cookie);
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// A guest session is the only credential held by someone with no account, so
|
||||
/// there is nothing to disable when the workspace revokes guest access or the
|
||||
/// identity provider removes them — the expiry is the revocation. Much shorter
|
||||
/// than a member session for that reason.
|
||||
static ref GUEST_SESSION_VALIDITY_SECONDS: i64 = std::env::var("GUEST_SESSION_VALIDITY_SECONDS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i64>().ok())
|
||||
.unwrap_or(8 * 60 * 60);
|
||||
}
|
||||
|
||||
/// Mint a browser session for someone the identity provider authenticated who is a
|
||||
/// member of no workspace, so they can open one guest-mode app. Writes no `password`
|
||||
/// and no `usr` row: that absence is what keeps a guest off every seat counter, so
|
||||
/// nothing here may be "helpfully" upgraded into provisioning.
|
||||
///
|
||||
/// Pinned to `w_id` (`AuthCache` matches on `token.workspace_id`): without the pin an
|
||||
/// `apps:run:<path>` scope would unlock a same-path app elsewhere. So a guest cannot
|
||||
/// authenticate on any workspace-less route (`/api/users/*`, `/api/settings/*`); a
|
||||
/// page that needs one for a guest must become workspace-scoped, not loosen the pin.
|
||||
///
|
||||
/// Refuses unless every gate says yes (`guest_app_admits`, then the allowance in
|
||||
/// `guest_admission`), so no caller can mint where a guest is not wanted, whatever it
|
||||
/// believed when it decided to call. All that is left to the caller is the
|
||||
/// authentication of `email`.
|
||||
pub async fn create_guest_session_token<'c>(
|
||||
email: &str,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
|
||||
cookies: Cookies,
|
||||
) -> Result<String> {
|
||||
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
|
||||
|
||||
let token = rd_string(32);
|
||||
let t_hash = windmill_common::auth::hash_token(&token);
|
||||
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
|
||||
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
|
||||
None
|
||||
} else {
|
||||
Some(&token)
|
||||
};
|
||||
let scopes = windmill_api_auth::scopes::guest_session_scopes(app_path)?;
|
||||
|
||||
// No account at all (see `has_any_account`): an account holder is refused a guest
|
||||
// session, never handed a second, cheaper identity. The same helper the JWT arm uses.
|
||||
if windmill_common::users::has_any_account(&mut **tx, email).await? {
|
||||
return Err(Error::NotAuthorized(
|
||||
"an existing account cannot hold a guest session".to_string(),
|
||||
));
|
||||
}
|
||||
if !windmill_common::workspaces::guest_app_admits(&mut **tx, w_id, app_path).await? {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
windmill_common::workspaces::guest_admission(&mut **tx, email).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO token
|
||||
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)
|
||||
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
|
||||
t_hash,
|
||||
t_prefix,
|
||||
plaintext as Option<&str>,
|
||||
email,
|
||||
windmill_common::auth::GUEST_SESSION_LABEL,
|
||||
&GUEST_SESSION_VALIDITY_SECONDS.to_string(),
|
||||
&scopes,
|
||||
w_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// The only durable record that a guest was here, and the set the allowance is
|
||||
// counted on; not the audit log, see the migration. Idempotent per email,
|
||||
// workspace and day.
|
||||
sqlx::query!(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
VALUES ($1, $2, CURRENT_DATE)
|
||||
ON CONFLICT (email, workspace_id, day)
|
||||
DO UPDATE SET last_seen_at = now()",
|
||||
email,
|
||||
w_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut **tx,
|
||||
&AuditAuthor {
|
||||
email: email.to_string(),
|
||||
username: email.to_string(),
|
||||
username_override: None,
|
||||
token_prefix: Some(t_prefix.to_string()),
|
||||
},
|
||||
"users.login_guest",
|
||||
ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
Some([("entry", "idp")].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
set_session_cookie(&cookies, &token, *GUEST_SESSION_VALIDITY_SECONDS);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
@@ -3159,9 +3296,13 @@ async fn update_token_scopes(
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// A guest-labelled token is never rescoped: its scopes are its whole confinement,
|
||||
// and after promotion the same email owns an account that could otherwise strip
|
||||
// them from the still-valid guest credential. Same shape as the relabel guard.
|
||||
let updated: Option<String> = sqlx::query_scalar!(
|
||||
"UPDATE token SET scopes = $1
|
||||
WHERE email = $2 AND token_prefix = $3
|
||||
AND (label IS NULL OR label <> 'guest_session')
|
||||
RETURNING token_prefix",
|
||||
req.scopes.as_deref(),
|
||||
&authed.email,
|
||||
@@ -3172,7 +3313,7 @@ async fn update_token_scopes(
|
||||
|
||||
let prefix = updated.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"token {token_prefix} not found or not owned by user"
|
||||
"token {token_prefix} not found, not owned by user, or not rescopable"
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -3242,6 +3383,7 @@ async fn update_token_label(
|
||||
WHERE email = $2 AND token_prefix = $3
|
||||
AND (label IS NULL OR (
|
||||
label <> 'session'
|
||||
AND label <> 'guest_session'
|
||||
AND lower(label) NOT LIKE 'ephemeral%'
|
||||
AND label <> 'debugger-token'
|
||||
AND label NOT LIKE 'mcp-oauth-%'
|
||||
|
||||
@@ -416,6 +416,16 @@ async fn run_datatable_migrations(
|
||||
|
||||
let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?;
|
||||
|
||||
// How the user scoped the run, for the counter emitted on the first migration
|
||||
// that lands below.
|
||||
let scope = if query.only.is_some() {
|
||||
"only"
|
||||
} else if query.up_to.is_some() {
|
||||
"up_to"
|
||||
} else {
|
||||
"all"
|
||||
};
|
||||
|
||||
let mut applied = Vec::new();
|
||||
for m in migrations {
|
||||
if let Some(only) = query.only {
|
||||
@@ -453,6 +463,14 @@ async fn run_datatable_migrations(
|
||||
))
|
||||
})?;
|
||||
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
|
||||
// One event per run that moved the data table forward, emitted on the
|
||||
// first migration that lands rather than after the loop: a later one
|
||||
// failing returns early, and that run still advanced the data table. A
|
||||
// run with nothing pending stays uncounted — it is the common outcome of
|
||||
// opening the list and would drown out the runs that did something.
|
||||
if applied.len() == 1 {
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migration_run", scope);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(RunDatatableMigrationsResult { applied }))
|
||||
@@ -594,6 +612,12 @@ async fn rollback_datatable_migrations(
|
||||
))
|
||||
})?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_rollback",
|
||||
if query.only.is_some() { "only" } else { "last" },
|
||||
);
|
||||
|
||||
Ok(Json(RollbackDatatableMigrationsResult {
|
||||
rolled_back: vec![RolledBackMigration { version, name: definition.name }],
|
||||
}))
|
||||
@@ -824,6 +848,8 @@ async fn enable_datatable_migrations(
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "on");
|
||||
|
||||
Ok(format!(
|
||||
"Enabled migrations for data table {datatable_name}"
|
||||
))
|
||||
@@ -892,6 +918,8 @@ async fn disable_datatable_migrations(
|
||||
.await?;
|
||||
}
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "off");
|
||||
|
||||
Ok(format!(
|
||||
"Disabled migrations for data table {datatable_name} and deleted its migrations"
|
||||
))
|
||||
@@ -1134,6 +1162,8 @@ async fn create_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "migration_created", "manual");
|
||||
|
||||
Ok(Json(DatatableMigration {
|
||||
datatable: datatable_name,
|
||||
timestamp,
|
||||
@@ -1371,6 +1401,20 @@ async fn upsert_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An unchanged re-push is not counted: `wmill sync push` sends every migration
|
||||
// on every sync, so counting those would swamp the definitions people write.
|
||||
if !unchanged {
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_created",
|
||||
if existing.is_none() {
|
||||
"synced"
|
||||
} else {
|
||||
"edited"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Upserted migration {} in {}",
|
||||
payload.timestamp, datatable_name
|
||||
@@ -1477,6 +1521,12 @@ async fn generate_initial_datatable_migration(
|
||||
)
|
||||
.await?;
|
||||
|
||||
windmill_common::feature_usage::log_feature_usage(
|
||||
"datatable",
|
||||
"migration_created",
|
||||
"initial_snapshot",
|
||||
);
|
||||
|
||||
Ok(Json(DatatableMigration {
|
||||
datatable: datatable_name,
|
||||
timestamp,
|
||||
|
||||
@@ -12,10 +12,10 @@ use windmill_api_auth::{
|
||||
};
|
||||
use windmill_api_users::users::WorkspaceInvite;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||||
use windmill_common::webhook::WebhookShared;
|
||||
use windmill_common::{BASE_URL, DB};
|
||||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -151,6 +151,9 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
|
||||
.route("/edit_default_app", post(edit_default_app))
|
||||
.route("/edit_guest_access", post(edit_guest_access))
|
||||
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
|
||||
.route("/guest_usage", get(get_guest_usage))
|
||||
.route("/default_app", get(get_default_app))
|
||||
.route(
|
||||
"/default_scripts",
|
||||
@@ -317,6 +320,17 @@ pub struct WorkspaceSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
pub error_handler_fallback_to_instance_alerts: bool,
|
||||
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
|
||||
/// own `execution_mode: guest` is inert while this is off.
|
||||
pub guest_access_enabled: bool,
|
||||
/// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most
|
||||
/// one (a DB CHECK enforces it). Public material, not a secret, so it is admin-
|
||||
/// readable here. `None`/`None` falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guest_jwt_public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guest_jwt_jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
|
||||
@@ -339,6 +353,9 @@ pub struct WorkspacePublicSettings {
|
||||
pub teams_team_guid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mute_critical_alerts: Option<bool>,
|
||||
/// Not sensitive, and the app editor needs it to say whether the guest rung is
|
||||
/// live -- an app can be set to `guest` while the workspace has guests off.
|
||||
pub guest_access_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deploy_ui: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1073,7 +1090,10 @@ async fn get_settings(
|
||||
error_handler,
|
||||
success_handler,
|
||||
public_app_execution_limit_per_minute,
|
||||
error_handler_fallback_to_instance_alerts
|
||||
error_handler_fallback_to_instance_alerts,
|
||||
guest_access_enabled,
|
||||
guest_jwt_public_key,
|
||||
guest_jwt_jwks_url
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
@@ -1112,6 +1132,7 @@ async fn get_public_settings(
|
||||
teams_team_name,
|
||||
teams_team_guid,
|
||||
mute_critical_alerts,
|
||||
guest_access_enabled,
|
||||
deploy_ui,
|
||||
large_file_storage,
|
||||
datatable
|
||||
@@ -1132,6 +1153,18 @@ async fn get_public_settings(
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
/// The instance's standing against the guest allowance: counts only, no emails, so any
|
||||
/// member may read it. Instance-wide, since a licence is per instance and one email is
|
||||
/// one guest however many workspaces it opens; the settings card and the editor's
|
||||
/// Guests rung show it so nobody discovers the cap from a visitor's complaint.
|
||||
async fn get_guest_usage(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
) -> JsonResult<windmill_common::workspaces::GuestUsage> {
|
||||
Ok(Json(windmill_common::workspaces::guest_usage(&db).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GitSyncDeployModeQuery {
|
||||
/// The branch the caller would push.
|
||||
@@ -3491,6 +3524,9 @@ async fn edit_datatable_config(
|
||||
// Migrations opt-in is owned by the enable/disable endpoints, not this config
|
||||
// form: preserve each existing data table's flag, and default brand-new data
|
||||
// tables to enabled.
|
||||
// Counted here rather than after the write because this is where a rename is
|
||||
// still distinguishable from a creation; emitted once the commit lands.
|
||||
let mut created_substrates: Vec<&'static str> = Vec::new();
|
||||
for (name, dt) in new_config.settings.datatables.iter_mut() {
|
||||
let lookup = rename_src
|
||||
.get(name.as_str())
|
||||
@@ -3498,7 +3534,15 @@ async fn edit_datatable_config(
|
||||
.unwrap_or(name.as_str());
|
||||
dt.migrations_enabled = match old_datatables.get(lookup) {
|
||||
Some(old) => old.migrations_enabled,
|
||||
None => Some(true),
|
||||
None => {
|
||||
// Keyed by how the substrate is serialized into `workspace_settings`,
|
||||
// so these line up with the `datatable_configured` adoption counts.
|
||||
created_substrates.push(match dt.database.resource_type {
|
||||
DataTableCatalogResourceType::Instance => "instance",
|
||||
DataTableCatalogResourceType::Postgresql => "postgresql",
|
||||
});
|
||||
Some(true)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3556,6 +3600,10 @@ async fn edit_datatable_config(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
for substrate in created_substrates {
|
||||
windmill_common::feature_usage::log_feature_usage("datatable", "created", substrate);
|
||||
}
|
||||
|
||||
crate::datatable_migrations::record_datatable_cascade_deployments(
|
||||
&authed,
|
||||
&db,
|
||||
@@ -4595,6 +4643,110 @@ async fn edit_default_app(
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditGuestAccess {
|
||||
guest_access_enabled: bool,
|
||||
}
|
||||
|
||||
/// Turn guest sessions on or off for this workspace. Off by default, and off is
|
||||
/// authoritative and immediate: the switch is re-read where a guest session is
|
||||
/// minted (`guest_app_admits`) and at the auth door on every guest request, so an app
|
||||
/// whose policy already says `guest` — pushed by git-sync, say — closes to guests on
|
||||
/// the next request, sessions already issued included.
|
||||
async fn edit_guest_access(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
guest_access_enabled,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_guest_access",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&guest_access_enabled.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Guest access set to {guest_access_enabled} for workspace {w_id}"
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditGuestJwtKey {
|
||||
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
|
||||
/// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||||
/// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`.
|
||||
public_key: Option<String>,
|
||||
jwks_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
|
||||
/// Workspace-admin gated, like the guest switch: guests are free up to the instance
|
||||
/// allowance on any plan, so configuring their key needs no licence. The key is
|
||||
/// validated before it is stored so a typo is refused here, not silently on every guest
|
||||
/// later: a PEM must parse as an RS/ES public key (HS* has no PEM form and is
|
||||
/// unreachable), and a JWKS URL must be fetchable and hold at least one usable signing key.
|
||||
async fn edit_guest_jwt_key(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(EditGuestJwtKey { public_key, jwks_url }): Json<EditGuestJwtKey>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let public_key = public_key.filter(|s| !s.trim().is_empty());
|
||||
let jwks_url = jwks_url.filter(|s| !s.trim().is_empty());
|
||||
if public_key.is_some() && jwks_url.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"Set a PEM public key or a JWKS URL, not both".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(pem) = public_key.as_deref() {
|
||||
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
|
||||
}
|
||||
if let Some(url) = jwks_url.as_deref() {
|
||||
windmill_common::guest_jwt::fetch_jwks(url).await?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
|
||||
public_key,
|
||||
jwks_url,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_guest_jwt_key",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Guest JWT key updated for workspace {w_id}"))
|
||||
}
|
||||
|
||||
async fn edit_default_scripts(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -11106,6 +11258,7 @@ async fn load_workspace_authed(
|
||||
token_prefix: base_authed.token_prefix.clone(),
|
||||
read_only: base_authed.read_only,
|
||||
job_id: base_authed.job_id,
|
||||
credential_expiry: base_authed.credential_expiry,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11138,6 +11291,7 @@ async fn load_workspace_authed(
|
||||
token_prefix: base_authed.token_prefix.clone(),
|
||||
read_only: base_authed.read_only,
|
||||
job_id: base_authed.job_id,
|
||||
credential_expiry: base_authed.credential_expiry,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
@@ -187,6 +187,13 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating guest_activity table");
|
||||
sqlx::query("UPDATE guest_activity SET workspace_id = $1 WHERE workspace_id = $2")
|
||||
.bind(&rw.new_id)
|
||||
.bind(&old_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_invite table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
@@ -1112,6 +1119,13 @@ pub(crate) async fn delete_workspace(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Unlike the rest of this list, this also moves an instance-wide figure: the guest
|
||||
// allowance and the seats past it are counted over every workspace's rows.
|
||||
sqlx::query("DELETE FROM guest_activity WHERE workspace_id = $1")
|
||||
.bind(&w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM token WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.803.0
|
||||
version: 1.804.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -861,6 +861,34 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/ExternalJwtToken"
|
||||
|
||||
/users/guests:
|
||||
get:
|
||||
summary: list the distinct guests of the trailing window (superadmin only)
|
||||
description: >-
|
||||
The set the guest allowance is counted on: every distinct email that held a
|
||||
guest session in the last `window_days`, with the workspaces it opened and the
|
||||
days it was first and last seen, most recently seen first. `usage` is the
|
||||
instance's standing against the allowance and the meter.
|
||||
operationId: listGuests
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: per_page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: the guests of the window and the allowance they count against
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestList"
|
||||
|
||||
/users/onboarding:
|
||||
post:
|
||||
summary: Submit user onboarding data
|
||||
@@ -3667,8 +3695,12 @@ paths:
|
||||
$ref: "#/components/schemas/WorkspaceDeployUISettings"
|
||||
mute_critical_alerts:
|
||||
type: boolean
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
required:
|
||||
- workspace_id
|
||||
- guest_access_enabled
|
||||
|
||||
/w/{workspace}/workspaces/get_settings:
|
||||
get:
|
||||
@@ -3750,6 +3782,15 @@ paths:
|
||||
error_handler_fallback_to_instance_alerts:
|
||||
type: boolean
|
||||
description: Report failed jobs to the instance critical alert channels when no workspace error handler is set.
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
guest_jwt_public_key:
|
||||
type: string
|
||||
description: PEM public key a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_jwks_url`.
|
||||
guest_jwt_jwks_url:
|
||||
type: string
|
||||
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -5739,6 +5780,97 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_access:
|
||||
post:
|
||||
summary: enable or disable guest sessions for this workspace
|
||||
description: >-
|
||||
Guests are people the identity provider authenticates who have no Windmill
|
||||
account; the `guest` app execution mode admits them. Off by default. Re-read
|
||||
where a guest session is minted and at the auth door on every guest request, so
|
||||
turning it off takes effect immediately, for sessions already issued and for
|
||||
apps whose policy already says `guest`.
|
||||
operationId: editGuestAccess
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Whether guest sessions are admitted
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- guest_access_enabled
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_jwt_key:
|
||||
post:
|
||||
summary: set the key guest JWTs are verified against for this workspace
|
||||
description: >-
|
||||
A guest JWT (`jwt_guest_`) is minted by the embedding customer's own backend and
|
||||
verified against this key: a PEM public key (RS/ES family, HS* refused) or a JWKS
|
||||
URL, at most one. Both empty clears the workspace key; off cloud, verification then
|
||||
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
|
||||
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
|
||||
The key is validated before it is stored.
|
||||
operationId: editGuestJwtKey
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: The guest JWT verification key
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
public_key:
|
||||
type: string
|
||||
description: A PEM public key (RS or ES family).
|
||||
jwks_url:
|
||||
type: string
|
||||
description: A JWKS URL whose keys are fetched and refreshed.
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/guest_usage:
|
||||
get:
|
||||
summary: the instance's standing against the guest allowance
|
||||
description: >-
|
||||
Instance-wide, since a licence is per instance and one email is one guest however
|
||||
many workspaces it opens. Read by workspace admins and app publishers to see how
|
||||
close the cap (Community and Pro) or the meter (Enterprise) is.
|
||||
operationId: getGuestUsage
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: guest usage
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestUsage"
|
||||
|
||||
/w/{workspace}/workspaces/default_scripts:
|
||||
post:
|
||||
summary: edit default scripts for workspace
|
||||
@@ -8806,6 +8938,27 @@ paths:
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps_u/guest_entry_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: whether the app behind a custom path admits guests
|
||||
description: >-
|
||||
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
|
||||
the app's execution mode is `guest` AND its workspace has
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`.
|
||||
Returns the workspace too, since a custom URL may not carry it.
|
||||
operationId: getGuestEntryByCustomPath
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/CustomPath"
|
||||
responses:
|
||||
"200":
|
||||
description: the app is open to guests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestEntry"
|
||||
|
||||
/apps_u/public_app_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: get public app by custom path
|
||||
@@ -12966,6 +13119,30 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps_u/guest_entry/{path}:
|
||||
get:
|
||||
summary: whether the app behind a share secret admits guests
|
||||
description: >-
|
||||
Unauthenticated: what a signed-out visitor reads to learn that signing in
|
||||
would let them in. 404 unless the app's execution mode is `guest` AND the
|
||||
workspace has `guest_access_enabled` AND the instance has not set the
|
||||
`guest_access_disabled` global setting, so it says nothing about apps that
|
||||
are not open to guests. Discloses only the app path, to a caller already
|
||||
holding the share secret.
|
||||
operationId: getGuestEntry
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: the app is open to guests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestEntry"
|
||||
|
||||
/w/{workspace}/apps_u/public_app/{path}:
|
||||
get:
|
||||
summary: get public app by secret
|
||||
@@ -28748,6 +28925,76 @@ components:
|
||||
- is_operator
|
||||
- last_used_at
|
||||
|
||||
GuestUsage:
|
||||
type: object
|
||||
description: >-
|
||||
Guests are free up to `free_allowance` distinct emails over the trailing
|
||||
`window_days`. Past that an Enterprise plan meters them (`metered`, four guests
|
||||
to one seat: `billable_guests`, `guest_seats`); every other plan and build
|
||||
admits no new email until the count drops. `instance_enabled` is the superadmin
|
||||
switch (`guest_access_disabled` global setting) every workspace switch sits under.
|
||||
properties:
|
||||
instance_enabled:
|
||||
type: boolean
|
||||
guest_count:
|
||||
type: integer
|
||||
format: int64
|
||||
window_days:
|
||||
type: integer
|
||||
free_allowance:
|
||||
type: integer
|
||||
format: int64
|
||||
metered:
|
||||
type: boolean
|
||||
billable_guests:
|
||||
type: integer
|
||||
format: int64
|
||||
guest_seats:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- instance_enabled
|
||||
- guest_count
|
||||
- window_days
|
||||
- free_allowance
|
||||
- metered
|
||||
- billable_guests
|
||||
- guest_seats
|
||||
|
||||
GuestActivity:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
workspaces:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
first_seen:
|
||||
type: string
|
||||
format: date
|
||||
last_seen:
|
||||
type: string
|
||||
format: date
|
||||
required:
|
||||
- email
|
||||
- workspaces
|
||||
- first_seen
|
||||
- last_seen
|
||||
|
||||
GuestList:
|
||||
type: object
|
||||
properties:
|
||||
usage:
|
||||
$ref: "#/components/schemas/GuestUsage"
|
||||
guests:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GuestActivity"
|
||||
required:
|
||||
- usage
|
||||
- guests
|
||||
|
||||
NewToken:
|
||||
type: object
|
||||
properties:
|
||||
@@ -33158,14 +33405,17 @@ components:
|
||||
type: string
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
description: >-
|
||||
Who the app's runnables execute as. Optional, and what omitting it
|
||||
means depends on the operation: creating an app defaults it to
|
||||
`publisher` (runs on behalf of the app's publisher and requires an
|
||||
authenticated viewer), while updating one keeps the mode the app is
|
||||
already deployed under. Either way `anonymous`, which makes the app
|
||||
publicly executable, is never assumed
|
||||
Who may open the app, and who its runnables execute as. Optional, and
|
||||
what omitting it means depends on the operation: creating an app
|
||||
defaults it to `publisher` (runs on behalf of the app's publisher and
|
||||
requires an authenticated viewer), while updating one keeps the mode
|
||||
the app is already deployed under. Neither `anonymous`, which makes
|
||||
the app publicly executable, nor `guest`, which opens it to anyone the
|
||||
identity provider authenticates, is ever assumed. A guest is only
|
||||
admitted where the workspace also has `guest_access_enabled`, which is
|
||||
checked when the session is minted and again on every guest request
|
||||
on_behalf_of:
|
||||
type: string
|
||||
on_behalf_of_email:
|
||||
@@ -33216,7 +33466,7 @@ components:
|
||||
format: date-time
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
raw_app:
|
||||
type: boolean
|
||||
labels:
|
||||
@@ -35030,6 +35280,17 @@ components:
|
||||
description: Configuration of protection restrictions
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleKind"
|
||||
GuestEntry:
|
||||
type: object
|
||||
description: What a signed-out visitor needs to start a guest sign-in.
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
app_path:
|
||||
type: string
|
||||
required:
|
||||
- workspace_id
|
||||
- app_path
|
||||
ProtectionRuleKind:
|
||||
type: string
|
||||
enum:
|
||||
@@ -35038,6 +35299,7 @@ components:
|
||||
- RestrictDeployToDeployers
|
||||
- RestrictAnonymousAppDeployment
|
||||
- RestrictPublicRunSharing
|
||||
- RestrictGuestAppDeployment
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
|
||||
@@ -170,6 +170,7 @@ pub fn unauthed_service() -> Router {
|
||||
)
|
||||
.route("/load_csv_preview/{*path}", get(app_load_csv_preview))
|
||||
.route("/public_app/{secret}", get(get_public_app_by_secret))
|
||||
.route("/guest_entry/{secret}", get(get_guest_entry))
|
||||
.route("/embed_token/{secret}", get(get_app_embed_token))
|
||||
.route("/public_resource/{*path}", get(get_public_resource))
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
@@ -288,6 +289,15 @@ pub type AllowUserResources = Vec<String>;
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExecutionMode {
|
||||
Anonymous,
|
||||
/// Login required, workspace membership not: anyone the instance's identity
|
||||
/// provider authenticates may open the app, and the runnables execute as the
|
||||
/// publisher exactly as in [`ExecutionMode::Publisher`]. Such a viewer holds a
|
||||
/// guest session: an identity with no account at all (no `password` row, no `usr`
|
||||
/// row anywhere), which is what keeps it off every row-based seat counter; what a
|
||||
/// guest costs instead is the allowance in `windmill_common::workspaces`. Honored
|
||||
/// only where `workspace_settings.guest_access_enabled` is on, re-read at the auth
|
||||
/// door on every guest request.
|
||||
Guest,
|
||||
/// Default for a policy that omits `execution_mode`. It MUST stay a mode
|
||||
/// that requires an authenticated viewer: an omitted field must never be
|
||||
/// able to publish an app anonymously (publicly executable).
|
||||
@@ -296,6 +306,136 @@ pub enum ExecutionMode {
|
||||
Viewer,
|
||||
}
|
||||
|
||||
impl ExecutionMode {
|
||||
/// The serialized form, matching this enum's `rename_all = "lowercase"`.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ExecutionMode::Anonymous => "anonymous",
|
||||
ExecutionMode::Guest => "guest",
|
||||
ExecutionMode::Publisher => "publisher",
|
||||
ExecutionMode::Viewer => "viewer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The protection rule gating a *transition into* `mode`, if any. Anonymous and
|
||||
/// guest each widen who may open an app past the workspace's own members, so each
|
||||
/// carries its own rule; the two member-only modes are ungated.
|
||||
fn deployment_rule_for_mode(mode: ExecutionMode) -> Option<ProtectionRuleKind> {
|
||||
match mode {
|
||||
ExecutionMode::Anonymous => Some(ProtectionRuleKind::RestrictAnonymousAppDeployment),
|
||||
ExecutionMode::Guest => Some(ProtectionRuleKind::RestrictGuestAppDeployment),
|
||||
ExecutionMode::Publisher | ExecutionMode::Viewer => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest session is scoped to its app by path, so an app whose path the scope
|
||||
/// grammar cannot hold as one literal (`is_scope_literal_path`) can never admit a
|
||||
/// guest; refuse the mode at deploy time rather than advertise an app nobody enters.
|
||||
/// `path` is where the app ends up: on a rename, the destination.
|
||||
fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> {
|
||||
if matches!(mode, ExecutionMode::Guest) && !windmill_common::auth::is_scope_literal_path(path) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"app {path} cannot be set to Guests: a path with `:`, `,` or `*`, or a leading `/`, \
|
||||
cannot be scoped"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gate a viewer on the app's `execution_mode`, as far as can be decided without an
|
||||
/// ACL probe. `Ok(true)` means already authorized — anonymous admits anyone, guest
|
||||
/// admits anyone signed in; `Ok(false)` means the caller is a member and still owes
|
||||
/// the read-access check its caller performs.
|
||||
///
|
||||
/// A guest is authorized by its token's scope and never by an ACL probe: it holds no
|
||||
/// `usr` row, so RLS finds nothing for it and every guest would read as having no
|
||||
/// access. That scope is also what keeps a guest session to the one app it was minted
|
||||
/// for, even though the mode itself admits anyone signed in. The workspace's guest
|
||||
/// switch is not checked here: `AuthCache` enforces it for every guest request.
|
||||
pub fn authorize_non_member_viewer(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<bool> {
|
||||
if matches!(mode, ExecutionMode::Anonymous) {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
};
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
if matches!(mode, ExecutionMode::Guest) {
|
||||
if is_guest {
|
||||
check_scopes(authed, || format!("apps:read:{}", app_path))?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if is_guest {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Confines a guest to its app once the app's mode is known; a no-op for every other
|
||||
/// caller. An anonymous app is open to anyone, so the guest stays the caller there,
|
||||
/// as itself: the run and the reads that follow it (job results, S3 provenance) must
|
||||
/// carry one identity. Anywhere else a mismatch is refused.
|
||||
fn guest_caller_for_mode(
|
||||
opt_authed: Option<ApiAuthed>,
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
) -> Result<Option<ApiAuthed>> {
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
|
||||
|| matches!(mode, ExecutionMode::Anonymous)
|
||||
{
|
||||
return Ok(opt_authed);
|
||||
}
|
||||
check_scopes(authed, || format!("apps:run:{app_path}"))
|
||||
.or_else(|_| check_scopes(authed, || format!("apps:read:{app_path}")))?;
|
||||
Ok(opt_authed)
|
||||
}
|
||||
|
||||
/// [`authorize_non_member_viewer`] plus the member read-access probe, for the
|
||||
/// entry points that address an app by id.
|
||||
async fn authorize_app_viewer(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
app_id: i64,
|
||||
w_id: &str,
|
||||
user_db: &UserDB,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<()> {
|
||||
if authorize_non_member_viewer(mode, app_path, opt_authed)? {
|
||||
return Ok(());
|
||||
}
|
||||
let authed = opt_authed
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::internal_err("authorize_app_viewer: unauthenticated".to_string()))?;
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
app_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct PolicyTriggerableInputs {
|
||||
static_inputs: StaticFields,
|
||||
@@ -1218,29 +1358,15 @@ async fn get_public_app_by_secret(
|
||||
|
||||
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
|
||||
|
||||
if !matches!(policy.execution_mode(), ExecutionMode::Anonymous) {
|
||||
if opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
} else {
|
||||
let authed = opt_authed.unwrap();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
authorize_app_viewer(
|
||||
policy.execution_mode(),
|
||||
&app.path,
|
||||
id,
|
||||
&w_id,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Compute bundle_secret for raw apps
|
||||
if app.raw_app {
|
||||
@@ -1356,9 +1482,18 @@ async fn mint_raw_app_sdk_token(
|
||||
ensure_scopes_within_caller(authed, Some(scopes))?;
|
||||
let mut scopes = scopes.to_vec();
|
||||
scopes.push(windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string());
|
||||
let expiration = chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let requested_exp =
|
||||
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let (label, expiration) =
|
||||
match guest_derived_token_constraints(db, authed, requested_exp).await? {
|
||||
Some((label, exp)) => {
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("sdk_app:{app_path}"), requested_exp),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("sdk_app:{app_path}")),
|
||||
Some(label),
|
||||
Some(expiration),
|
||||
None,
|
||||
Some(scopes),
|
||||
@@ -1373,6 +1508,43 @@ async fn mint_raw_app_sdk_token(
|
||||
Ok((token, expiration))
|
||||
}
|
||||
|
||||
/// Label and expiry a token minted *by* a guest session must carry, or `None` for a
|
||||
/// non-guest minter. The label is what lets it resolve; the caller pushes the `guest`
|
||||
/// sentinel so every guest control still applies; the expiry is capped at the parent's,
|
||||
/// since that expiry is a guest's only revocation short of logging out.
|
||||
async fn guest_derived_token_constraints(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
requested: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<Option<(String, chrono::DateTime<chrono::Utc>)>> {
|
||||
if !windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A guest JWT carries its own expiry and has no token row to look up; a signed-in
|
||||
// guest session is a row found by prefix (MIN is the conservative side of a
|
||||
// theoretical prefix collision). Either way the derived token caps on it, never on
|
||||
// a fresh interval.
|
||||
let parent_exp = if let Some(exp) = authed.credential_expiry {
|
||||
exp
|
||||
} else {
|
||||
let parent: Option<Option<chrono::DateTime<chrono::Utc>>> = sqlx::query_scalar(
|
||||
"SELECT MIN(expiration) FROM token WHERE token_prefix = $1 AND email = $2 AND label = $3",
|
||||
)
|
||||
.bind(authed.token_prefix.as_deref().unwrap_or(""))
|
||||
.bind(&authed.email)
|
||||
.bind(windmill_common::auth::GUEST_SESSION_LABEL)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
parent.flatten().ok_or_else(|| {
|
||||
Error::NotAuthorized("guest session not found or has no expiry".to_string())
|
||||
})?
|
||||
};
|
||||
Ok(Some((
|
||||
windmill_common::auth::GUEST_SESSION_LABEL.to_string(),
|
||||
requested.min(parent_exp),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Shared tail of the three embed-token endpoints: which credential the viewer
|
||||
/// gets. Sandboxed low-code gets the embed token; a sandboxed raw app declaring
|
||||
/// `frontend_sdk_scopes` gets the SDK token once `sdk_consent` is set — the
|
||||
@@ -1400,7 +1572,22 @@ pub async fn build_embed_token_response(
|
||||
&& opt_authed.is_some()
|
||||
&& !policy.frontend_sdk_scopes.is_empty()
|
||||
{
|
||||
Some(policy.frontend_sdk_scopes.clone())
|
||||
// An SDK token runs as the viewer, and a guest's session is the ceiling on what
|
||||
// it may delegate — the mint enforces that. Advertise only what a guest can
|
||||
// actually be granted, so the consent prompt never promises a scope the mint
|
||||
// would then refuse.
|
||||
let declared = policy.frontend_sdk_scopes.clone();
|
||||
let offered = match opt_authed {
|
||||
Some(a) if windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()) => {
|
||||
let held = a.scopes.as_deref().unwrap_or_default();
|
||||
declared
|
||||
.into_iter()
|
||||
.filter(|sc| held.iter().any(|h| h == sc))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
_ => declared,
|
||||
};
|
||||
(!offered.is_empty()).then_some(offered)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1556,9 +1743,13 @@ pub async fn mint_app_embed_token(
|
||||
"App embed tokens cannot mint or renew embed tokens".to_string(),
|
||||
));
|
||||
}
|
||||
let expiration =
|
||||
let requested_exp =
|
||||
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES
|
||||
.iter()
|
||||
.filter(|s| **s != windmill_api_auth::scopes::APP_EMBED_SENTINEL)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// Path-scoped read so the app can fetch its OWN definition (apps/get/p,
|
||||
// which the in-workspace sandboxed viewer uses) — but no other app's. The
|
||||
// public viewer fetches via apps_u/public_app and doesn't rely on this.
|
||||
@@ -1569,10 +1760,22 @@ pub async fn mint_app_embed_token(
|
||||
scopes.push(format!("apps:run:{app_path}"));
|
||||
// A scope-restricted caller token must not bootstrap a broader-scoped
|
||||
// embed token (`create_token_internal` deliberately does not check this
|
||||
// itself). No-op for unscoped sessions — the normal embed flow.
|
||||
// itself). Checked on the real scopes only: a sentinel is a one-part string
|
||||
// that `ScopeDefinition::from_scope_string` rejects, so leaving it in the
|
||||
// requested set makes this fail outright for any scoped caller — which a
|
||||
// guest session is. `mint_raw_app_sdk_token` has the same shape.
|
||||
ensure_scopes_within_caller(authed, Some(&scopes))?;
|
||||
scopes.push(windmill_api_auth::scopes::APP_EMBED_SENTINEL.to_string());
|
||||
let (label, expiration) =
|
||||
match guest_derived_token_constraints(db, authed, requested_exp).await? {
|
||||
Some((label, exp)) => {
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("embed_app:{app_path}"), requested_exp),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("embed_app:{app_path}")),
|
||||
Some(label),
|
||||
Some(expiration),
|
||||
None,
|
||||
Some(scopes),
|
||||
@@ -1601,6 +1804,40 @@ pub async fn mint_app_embed_token(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GuestEntry {
|
||||
/// The workspace and app path to name when starting a guest sign-in. The
|
||||
/// workspace is redundant on the secret route and load-bearing on the custom-path
|
||||
/// one, which may not carry it in its URL.
|
||||
pub workspace_id: String,
|
||||
pub app_path: String,
|
||||
}
|
||||
|
||||
/// Whether the app behind this share secret admits guests, and under what path.
|
||||
///
|
||||
/// Unauthenticated on purpose: it is what a signed-out visitor reads to learn that
|
||||
/// signing in would get them in. It discloses only the app's path, to a caller who
|
||||
/// already holds the share secret — the secret is the capability here. A 404 when the
|
||||
/// app is not open to guests, so it says nothing about apps that are not.
|
||||
async fn get_guest_entry(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<GuestEntry> {
|
||||
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
|
||||
let app = sqlx::query!(
|
||||
"SELECT path FROM app WHERE id = $1 AND workspace_id = $2",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let app = not_found_if_none(app, "App", id.to_string())?;
|
||||
if !windmill_common::workspaces::guest_app_admits(&db, &w_id, &app.path).await? {
|
||||
return Err(Error::NotFound("App is not open to guests".to_string()));
|
||||
}
|
||||
Ok(Json(GuestEntry { workspace_id: w_id, app_path: app.path }))
|
||||
}
|
||||
|
||||
/// Issue an embed token for a public app addressed by its (secret) share id.
|
||||
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
|
||||
/// reachable without auth, otherwise the caller must be logged in and have read
|
||||
@@ -1646,29 +1883,18 @@ async fn get_app_embed_token(
|
||||
|
||||
let authed_for_token = if policy.anonymous_execution {
|
||||
// Anonymous app: still mint a scoped token if the viewer happens to be
|
||||
// logged in (so the app sees their identity), otherwise stay anonymous.
|
||||
opt_authed
|
||||
// logged in (so the app sees their identity), otherwise stay anonymous. A
|
||||
// guest's session names another app and cannot contain this one's scopes,
|
||||
// so it renders anonymously here rather than being refused.
|
||||
opt_authed.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
} else {
|
||||
let authed = opt_authed.ok_or_else(|| {
|
||||
Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Some(authed)
|
||||
let mode = if policy.guest_execution {
|
||||
ExecutionMode::Guest
|
||||
} else {
|
||||
ExecutionMode::Publisher
|
||||
};
|
||||
authorize_app_viewer(mode, &app.path, id, &w_id, &user_db, &opt_authed).await?;
|
||||
opt_authed
|
||||
};
|
||||
|
||||
let resp = build_embed_token_response(
|
||||
@@ -1694,6 +1920,9 @@ async fn get_app_embed_token(
|
||||
/// strictest access interpretation.
|
||||
pub struct EmbedPolicyView {
|
||||
pub anonymous_execution: bool,
|
||||
/// Open to anyone the identity provider authenticates. Like
|
||||
/// `anonymous_execution`, an unknown mode reads as `false` — the strict side.
|
||||
pub guest_execution: bool,
|
||||
pub sandbox: bool,
|
||||
/// Raw apps: author-declared scopes for the frontend SDK token; empty when
|
||||
/// the app doesn't use the frontend SDK (non-string entries are ignored).
|
||||
@@ -1704,6 +1933,7 @@ pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
|
||||
let v: serde_json::Value = serde_json::from_str(policy_str).map_err(to_anyhow)?;
|
||||
Ok(EmbedPolicyView {
|
||||
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
|
||||
guest_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("guest"),
|
||||
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
frontend_sdk_scopes: v
|
||||
.get("frontend_sdk_scopes")
|
||||
@@ -2287,10 +2517,11 @@ async fn create_app_internal<'a>(
|
||||
// Pin the mode the app is created under, so the stored policy states one
|
||||
// even when the caller did not.
|
||||
app.policy.set_execution_mode(app.policy.execution_mode());
|
||||
if matches!(app.policy.execution_mode(), ExecutionMode::Anonymous) {
|
||||
refuse_unscopable_guest_app(&app.path, app.policy.execution_mode())?;
|
||||
if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
|
||||
&rule,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -3201,6 +3432,28 @@ async fn update_app_internal<'a>(
|
||||
if npath != path {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
|
||||
// The destination is what a guest session would be scoped to. A rename
|
||||
// that carries no policy keeps the deployed mode, read under the row
|
||||
// lock so a policy update landing alongside cannot slip a guest app
|
||||
// onto a path it cannot be scoped to.
|
||||
let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) {
|
||||
Some(mode) => mode,
|
||||
None => sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT policy->>'execution_mode' FROM app
|
||||
WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.and_then(|m| {
|
||||
serde_json::from_value::<ExecutionMode>(serde_json::Value::String(m)).ok()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
refuse_unscopable_guest_app(npath, mode)?;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
npath,
|
||||
@@ -3308,21 +3561,26 @@ async fn update_app_internal<'a>(
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if matches!(npolicy.execution_mode(), ExecutionMode::Anonymous) && !authed.is_admin {
|
||||
// Restricted users may keep deploying an app that is already
|
||||
// public, but flipping an app to anonymous (public) access is
|
||||
// gated by the RestrictAnonymousAppDeployment protection rule.
|
||||
// An unreadable deployed policy reads as not-anonymous, the
|
||||
// strict direction.
|
||||
let already_anonymous = deployed
|
||||
refuse_unscopable_guest_app(
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
npolicy.execution_mode(),
|
||||
)?;
|
||||
if let Some(rule) =
|
||||
deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin)
|
||||
{
|
||||
// Restricted users may keep deploying an app that is already open
|
||||
// to this audience, but widening one is gated by the matching
|
||||
// protection rule. An unreadable deployed policy reads as not
|
||||
// already-widened, the strict direction.
|
||||
let already_in_mode = deployed
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("execution_mode"))
|
||||
.and_then(|m| m.as_str())
|
||||
== Some("anonymous");
|
||||
if !already_anonymous {
|
||||
== Some(npolicy.execution_mode().as_str());
|
||||
if !already_in_mode {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
|
||||
&rule,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -3561,6 +3819,21 @@ async fn get_on_behalf_details_from_policy_and_authed(
|
||||
policy: &Policy,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<(String, String, String)> {
|
||||
// A guest acts only through an app open to guests — or to everyone. A members-only
|
||||
// mode means the policy changed after the session was issued. Decided here, in the
|
||||
// one resolver every on-behalf path (runs, S3 reads, uploads) goes through.
|
||||
if opt_authed
|
||||
.as_ref()
|
||||
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
&& !matches!(
|
||||
policy.execution_mode(),
|
||||
ExecutionMode::Guest | ExecutionMode::Anonymous
|
||||
)
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"this app is not open to guests".to_string(),
|
||||
));
|
||||
}
|
||||
let (username, permissioned_as, email) = match policy.execution_mode() {
|
||||
ExecutionMode::Anonymous => {
|
||||
let username = opt_authed
|
||||
@@ -3570,7 +3843,9 @@ async fn get_on_behalf_details_from_policy_and_authed(
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Publisher => {
|
||||
// Guest runs as the publisher exactly as Publisher does; the two differ only
|
||||
// in who is let through the door, which is settled before we get here.
|
||||
ExecutionMode::Publisher | ExecutionMode::Guest => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
@@ -3655,8 +3930,12 @@ async fn execute_component(
|
||||
// Authorize before touching the payload: the route layer is resource-blind, so a
|
||||
// path-scoped caller (app embed token, or a picker-minted `apps:run|write:<path>`)
|
||||
// is confined to its own app only here. No-op for unscoped callers; anonymous ones
|
||||
// are policy-gated below.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
// are policy-gated below, and a guest's confinement waits for the app's mode
|
||||
// (`guest_caller_for_mode`).
|
||||
if let Some(authed) = opt_authed
|
||||
.as_ref()
|
||||
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
check_scopes(authed, || format!("apps:run:{}", path))?;
|
||||
}
|
||||
// Only honor temp_script_refs for the inline-script preview path:
|
||||
@@ -3873,8 +4152,16 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit for anonymous (public) executions
|
||||
if matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none() {
|
||||
// Rate limit for executions by callers the workspace does not know: anonymous
|
||||
// viewers, and guests — on an instance whose provider accepts any consumer
|
||||
// account, "anyone the IdP authenticates" is close to the anonymous population,
|
||||
// and each run costs a job as the publisher.
|
||||
let is_guest_caller = opt_authed
|
||||
.as_ref()
|
||||
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()));
|
||||
if (matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none())
|
||||
|| is_guest_caller
|
||||
{
|
||||
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
|
||||
if limit > 0 {
|
||||
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
|
||||
@@ -3882,6 +4169,8 @@ async fn execute_component(
|
||||
}
|
||||
}
|
||||
|
||||
let opt_authed = guest_caller_for_mode(opt_authed, policy.execution_mode(), path)?;
|
||||
|
||||
// Execution is publisher and an user is authenticated: check if the user is authorized to
|
||||
// execute the app.
|
||||
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref())
|
||||
@@ -4217,8 +4506,11 @@ async fn upload_s3_file_from_app(
|
||||
request: axum::extract::Request,
|
||||
) -> JsonResult<AppUploadFileResponse> {
|
||||
// Same path confinement as `execute_component`: without it a token scoped to app A
|
||||
// could drive app B's upload policy.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
// could drive app B's upload policy. A guest's waits for the app's mode, below.
|
||||
if let Some(authed) = opt_authed
|
||||
.as_ref()
|
||||
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
check_scopes(authed, || format!("apps:run:{}", path.to_path()))?;
|
||||
}
|
||||
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
|
||||
@@ -4271,6 +4563,14 @@ async fn upload_s3_file_from_app(
|
||||
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
|
||||
.transpose()?
|
||||
};
|
||||
let opt_authed = guest_caller_for_mode(
|
||||
opt_authed,
|
||||
policy
|
||||
.as_ref()
|
||||
.map(Policy::execution_mode)
|
||||
.unwrap_or_default(),
|
||||
path.to_path(),
|
||||
)?;
|
||||
|
||||
let user_db = UserDB::new(db.clone());
|
||||
|
||||
@@ -4428,8 +4728,12 @@ async fn upload_s3_file_from_app(
|
||||
}
|
||||
} else {
|
||||
// backward compatibility (no policy)
|
||||
// if no policy but logged in, use the user's auth to get the s3 resource
|
||||
if let Some(authed) = opt_authed {
|
||||
// if no policy but logged in, use the user's auth to get the s3 resource. A guest
|
||||
// has no standing of its own to upload with, so without a policy it is refused
|
||||
// exactly as an anonymous caller is.
|
||||
if let Some(authed) = opt_authed
|
||||
.filter(|a| !windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
let file_key = query
|
||||
.file_key
|
||||
.unwrap_or_else(|| get_random_file_name(query.file_extension));
|
||||
@@ -4687,6 +4991,7 @@ async fn get_on_behalf_authed_from_app(
|
||||
})
|
||||
};
|
||||
|
||||
let opt_authed = guest_caller_for_mode(opt_authed.clone(), policy.execution_mode(), path)?;
|
||||
let (username, permissioned_as, email) =
|
||||
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
|
||||
|
||||
@@ -4843,6 +5148,10 @@ fn check_app_s3_read_scope(opt_authed: &Option<ApiAuthed>, path: &str) -> Result
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
// A guest's confinement waits for the app's mode (`get_on_behalf_authed_from_app`).
|
||||
if windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
check_scopes(authed, || format!("apps:run:{}", path))
|
||||
.or_else(|_| check_scopes(authed, || format!("apps:read:{}", path)))
|
||||
}
|
||||
|
||||
@@ -1594,7 +1594,11 @@ pub(crate) async fn require_job_read_access(
|
||||
// this token, and letting it reach any job merely visible to the viewer would
|
||||
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
|
||||
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
// A guest stops here too: it has no membership behind it, so a share token whose
|
||||
// audience is the workspace's members must not read for it either.
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref())
|
||||
|| windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref())
|
||||
{
|
||||
return Err(Error::NotFound(format!("Job {job_id} not found")));
|
||||
}
|
||||
|
||||
@@ -11712,6 +11716,7 @@ mod approval_view_gate_tests {
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
job_id: None,
|
||||
credential_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ pub fn global_service() -> Router {
|
||||
.route("/rename/{user}", post(rename_user))
|
||||
.route("/onboarding", post(submit_onboarding_data))
|
||||
.route("/ext_jwt_tokens", get(list_ext_jwt_tokens))
|
||||
.route("/guests", get(list_guests))
|
||||
.route(
|
||||
"/offboard_preview/{user}",
|
||||
get(crate::offboarding::global_offboard_preview),
|
||||
@@ -141,6 +142,58 @@ async fn list_ext_jwt_tokens(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, sqlx::FromRow)]
|
||||
pub struct GuestActivity {
|
||||
pub email: String,
|
||||
pub workspaces: Vec<String>,
|
||||
pub first_seen: chrono::NaiveDate,
|
||||
pub last_seen: chrono::NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GuestList {
|
||||
pub usage: windmill_common::workspaces::GuestUsage,
|
||||
pub guests: Vec<GuestActivity>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListGuestsQuery {
|
||||
page: Option<usize>,
|
||||
per_page: Option<usize>,
|
||||
}
|
||||
|
||||
/// The distinct guests of the trailing window, the set the allowance is counted on,
|
||||
/// most recently seen first.
|
||||
async fn list_guests(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Query(query): Query<ListGuestsQuery>,
|
||||
) -> Result<Json<GuestList>> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
|
||||
let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination {
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
});
|
||||
let usage = windmill_common::workspaces::guest_usage(&db).await?;
|
||||
let guests = sqlx::query_as::<_, GuestActivity>(
|
||||
"SELECT email, array_agg(DISTINCT workspace_id) AS workspaces,
|
||||
MIN(day) AS first_seen, MAX(day) AS last_seen
|
||||
FROM guest_activity
|
||||
WHERE day > CURRENT_DATE - $3
|
||||
GROUP BY email
|
||||
ORDER BY MAX(day) DESC, email
|
||||
LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(per_page as i64)
|
||||
.bind(offset as i64)
|
||||
.bind(windmill_common::workspaces::GUEST_WINDOW_DAYS)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(GuestList { usage, guests }))
|
||||
}
|
||||
|
||||
async fn set_password(
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
};
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
|
||||
/// notifications and username overrides all key off them — so they must not be
|
||||
/// user-editable. `None` (no label) is treated as a user token.
|
||||
@@ -36,6 +36,7 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
// frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and
|
||||
// the SQL `lower(label) NOT LIKE 'ephemeral%'` guard.
|
||||
l != "session"
|
||||
&& l != GUEST_SESSION_LABEL
|
||||
&& !l.to_lowercase().starts_with("ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
@@ -56,9 +57,40 @@ pub fn is_server_minted_label(label: &str) -> bool {
|
||||
|| label.starts_with("ephemeral-script-end-user-")
|
||||
|| label == "ephemeral-script"
|
||||
|| label == "session"
|
||||
|| label == GUEST_SESSION_LABEL
|
||||
|| label.starts_with("mcp-oauth-")
|
||||
}
|
||||
|
||||
/// Label on a guest session (the `guest` app execution mode). This is the *grant*:
|
||||
/// `AuthCache` will resolve a token carrying it into an identity with no account behind
|
||||
/// it, which nothing else can do. It must therefore stay unforgeable, which is what
|
||||
/// listing it in [`is_server_minted_label`] buys — `/users/tokens/create` refuses it.
|
||||
///
|
||||
/// Do not move this test onto the token's scopes. Scopes on a user-minted token are
|
||||
/// caller-supplied and only ever *narrow* (`app_embed`, `raw_app_sdk`), so a scope
|
||||
/// that granted non-member access would be free for anyone to declare.
|
||||
pub const GUEST_SESSION_LABEL: &str = "guest_session";
|
||||
|
||||
/// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`].
|
||||
///
|
||||
/// Reserved in [`is_user_token`] as well as [`is_server_minted_label`]: the former
|
||||
/// gates relabelling, and a user token that could be relabelled *into* this
|
||||
/// namespace would become a guest session with no workspace pin — one that
|
||||
/// authenticates everywhere.
|
||||
pub fn is_guest_session_label(label: Option<&str>) -> bool {
|
||||
label == Some(GUEST_SESSION_LABEL)
|
||||
}
|
||||
|
||||
/// Whether `path` can be spliced into a scope as one literal resource. The scope
|
||||
/// grammar reserves three characters: `:` separates the parts, `,` separates
|
||||
/// resources, `*` is a wildcard. App paths are otherwise free-form (spaces, `@`). A
|
||||
/// leading `/` is refused too: routes strip it, so the scope would never match.
|
||||
pub fn is_scope_literal_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& !path.starts_with('/')
|
||||
&& !path.chars().any(|c| matches!(c, ':' | ',' | '*'))
|
||||
}
|
||||
|
||||
/// Whether `label` is the one minted for a browser session at login. [`is_server_minted_label`]
|
||||
/// stops a member minting it directly, but `/users/refresh_token` hands one to any authenticated
|
||||
/// caller, so this attributes a request to the UI without proving it: never gate authority on it.
|
||||
|
||||
@@ -65,6 +65,10 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics";
|
||||
pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics";
|
||||
pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
|
||||
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
|
||||
/// Superadmin switch over guest sessions for the whole instance, above the per-workspace
|
||||
/// one. Read from the table, uncached, by the same gates that read the workspace switch;
|
||||
/// the superadmin Guests list writes it through `/settings/global/{key}` by this name.
|
||||
pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled";
|
||||
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
|
||||
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
|
||||
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -71,6 +71,7 @@ bitflags::bitflags! {
|
||||
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
|
||||
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
|
||||
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
|
||||
const RESTRICT_GUEST_APP_DEPLOYMENT = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ pub enum ProtectionRuleKind {
|
||||
RestrictDeployToDeployers,
|
||||
RestrictAnonymousAppDeployment,
|
||||
RestrictPublicRunSharing,
|
||||
RestrictGuestAppDeployment,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
@@ -103,6 +105,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING
|
||||
}
|
||||
ProtectionRuleKind::RestrictGuestAppDeployment => {
|
||||
ProtectionRules::RESTRICT_GUEST_APP_DEPLOYMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +126,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
"Sharing a run publicly (readable without login) is restricted in this workspace"
|
||||
}
|
||||
ProtectionRuleKind::RestrictGuestAppDeployment => {
|
||||
"Opening an app to guests (anyone who can sign in) is restricted in this workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +775,185 @@ pub struct BillableSeats {
|
||||
pub seats: i64,
|
||||
}
|
||||
|
||||
/// Guests are free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the trailing
|
||||
/// `GUEST_WINDOW_DAYS`. Past that, an Enterprise plan meters them, `GUESTS_PER_SEAT`
|
||||
/// guests to one seat, while every other plan and build stops admitting new emails.
|
||||
pub const GUEST_WINDOW_DAYS: i32 = 30;
|
||||
pub const FREE_GUESTS_PER_WINDOW: i64 = 100;
|
||||
pub const GUESTS_PER_SEAT: i64 = 4;
|
||||
|
||||
/// Whether guests past the allowance are metered (Enterprise plan) rather than refused.
|
||||
/// A build without `enterprise` has no plan and is capped, like a Pro key.
|
||||
pub async fn guests_are_metered() -> bool {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
matches!(
|
||||
crate::ee_oss::get_license_plan().await,
|
||||
crate::ee_oss::LicensePlan::Enterprise
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Seats the guests past the free allowance consume: `ceil(billable / GUESTS_PER_SEAT)`.
|
||||
pub fn guest_seats(distinct_guests: i64) -> i64 {
|
||||
let billable = (distinct_guests - FREE_GUESTS_PER_WINDOW).max(0);
|
||||
(billable + GUESTS_PER_SEAT - 1) / GUESTS_PER_SEAT
|
||||
}
|
||||
|
||||
/// Distinct guest emails over the trailing window, today included.
|
||||
pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
|
||||
executor: E,
|
||||
) -> Result<i64> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT email) FROM guest_activity WHERE day > CURRENT_DATE - $1",
|
||||
)
|
||||
.bind(GUEST_WINDOW_DAYS)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("counting guests: {e:#}")))
|
||||
}
|
||||
|
||||
/// The instance's standing against the guest allowance, as every surface reports it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GuestUsage {
|
||||
/// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace
|
||||
/// switch sits under.
|
||||
pub instance_enabled: bool,
|
||||
/// Distinct guest emails over the trailing `window_days`.
|
||||
pub guest_count: i64,
|
||||
pub window_days: i32,
|
||||
pub free_allowance: i64,
|
||||
/// Enterprise plan: guests past the allowance take `guest_seats`. Otherwise no new
|
||||
/// email is admitted past it.
|
||||
pub metered: bool,
|
||||
pub billable_guests: i64,
|
||||
pub guest_seats: i64,
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. The
|
||||
/// setting is read as text before the cast so `true` and `"true"` both count.
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
format!(
|
||||
"NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \
|
||||
WHERE name = '{}'), false)",
|
||||
crate::global_settings::GUEST_ACCESS_DISABLED_SETTING
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_admits}"))
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?;
|
||||
let guest_count = guest_count_in_window(db).await?;
|
||||
let metered = guests_are_metered().await;
|
||||
let billable_guests = if metered {
|
||||
(guest_count - FREE_GUESTS_PER_WINDOW).max(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GuestUsage {
|
||||
instance_enabled,
|
||||
guest_count,
|
||||
window_days: GUEST_WINDOW_DAYS,
|
||||
free_allowance: FREE_GUESTS_PER_WINDOW,
|
||||
metered,
|
||||
billable_guests,
|
||||
guest_seats: if metered { guest_seats(guest_count) } else { 0 },
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `email` may be admitted as a guest right now. Checked once, where a session
|
||||
/// is minted: a returning guest (already in the window) is always let back in, so the
|
||||
/// cap only ever refuses a stranger, and a metered instance refuses nobody.
|
||||
///
|
||||
/// Must run inside the transaction that then records the guest in `guest_activity`:
|
||||
/// it takes a transaction-scoped lock so concurrent strangers count each other, and the
|
||||
/// lock is what keeps the cap exact rather than approximate.
|
||||
pub async fn guest_admission(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
|
||||
if guests_are_metered().await {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('guest_allowance'))")
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("locking the guest allowance: {e:#}")))?;
|
||||
let (in_window, count): (bool, i64) = sqlx::query_as(
|
||||
"SELECT
|
||||
EXISTS(SELECT 1 FROM guest_activity WHERE email = $1 AND day > CURRENT_DATE - $2),
|
||||
(SELECT COUNT(DISTINCT email) FROM guest_activity WHERE day > CURRENT_DATE - $2)",
|
||||
)
|
||||
.bind(email)
|
||||
.bind(GUEST_WINDOW_DAYS)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("checking the guest allowance: {e:#}")))?;
|
||||
if in_window || count < FREE_GUESTS_PER_WINDOW {
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::PermissionDenied(format!(
|
||||
"This instance has reached its limit of {FREE_GUESTS_PER_WINDOW} guests over \
|
||||
{GUEST_WINDOW_DAYS} days. Guest sign-in beyond that needs an Enterprise license."
|
||||
)))
|
||||
}
|
||||
|
||||
/// Whether a guest session for `email` in `w_id` still stands: the instance and the
|
||||
/// workspace admit guests, and the email still has no account. Read at the auth door on
|
||||
/// every guest request, so turning either switch off, or an account provisioned after
|
||||
/// the mint (or racing it), ends the session on its next request.
|
||||
pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Result<bool> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let stands: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT guest_access_enabled
|
||||
AND {instance_admits}
|
||||
AND NOT EXISTS(SELECT 1 FROM password WHERE email = $2)
|
||||
AND NOT EXISTS(SELECT 1 FROM usr WHERE email = $2)
|
||||
FROM workspace_settings WHERE workspace_id = $1"
|
||||
))
|
||||
.bind(w_id)
|
||||
.bind(email)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("checking the guest session of {email}: {e:#}")))?;
|
||||
Ok(stands.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Every switch at once: the instance's, the workspace's, and `app_path` being in
|
||||
/// `guest` execution mode. The single answer to "may a guest session be minted for this
|
||||
/// app", used by the mint itself and by the sign-in branch that decides whether to call
|
||||
/// it. A missing app or a policy with no stated mode reads as "no". The allowance is
|
||||
/// `guest_admission`.
|
||||
pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
|
||||
executor: E,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
) -> Result<bool> {
|
||||
// The mint refuses a path it cannot scope, so discovery must not advertise one.
|
||||
if !crate::auth::is_scope_literal_path(app_path) {
|
||||
return Ok(false);
|
||||
}
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let admits: Option<bool> = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false)
|
||||
AND {instance_admits}
|
||||
FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id
|
||||
WHERE app.workspace_id = $1 AND app.path = $2"
|
||||
))
|
||||
.bind(w_id)
|
||||
.bind(app_path)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("checking guest access to {w_id}/{app_path}: {e:#}"))
|
||||
})?;
|
||||
Ok(admits.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
|
||||
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
|
||||
///
|
||||
@@ -2804,3 +2991,17 @@ pub async fn dbt_warehouse_resource(
|
||||
.map(|t| t.to_string());
|
||||
Ok((path, target))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guest_allowance_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn guest_seats_round_up_past_the_allowance() {
|
||||
assert_eq!(guest_seats(0), 0);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW), 0);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + 1), 1);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + GUESTS_PER_SEAT), 1);
|
||||
assert_eq!(guest_seats(FREE_GUESTS_PER_WINDOW + GUESTS_PER_SEAT + 1), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct OAuthConfig {
|
||||
#[serde(default = "empty_string")]
|
||||
pub token_url: String,
|
||||
pub userinfo_url: Option<String>,
|
||||
/// The registry JSON may also carry `scope_options`, a frontend-only pick
|
||||
/// list for the connect dialog; it is deliberately not modelled here.
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// Default scopes for the client-credentials (2-legged) flow. These differ
|
||||
/// from the authorization-code `scopes` for most providers (member/consent
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1099,7 +1099,7 @@ pub async fn run_agent(
|
||||
// For non-Anthropic providers, response_format is handled by the query builder
|
||||
}
|
||||
|
||||
let user_wants_streaming = args.streaming.unwrap_or(false);
|
||||
let user_wants_streaming = streaming_requested(args.streaming);
|
||||
*has_stream = user_wants_streaming && is_text_output;
|
||||
|
||||
let mut final_events_str = String::new();
|
||||
@@ -1701,6 +1701,17 @@ pub async fn run_agent(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
|
||||
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
|
||||
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
|
||||
///
|
||||
/// The chat surfaces decide whether to open a stream from their own reading of the same config,
|
||||
/// and a surface that opens one for an answer sent in a single piece re-runs the flow when the
|
||||
/// connection times out. So this default is half of a contract, not a local preference.
|
||||
fn streaming_requested(streaming: Option<bool>) -> bool {
|
||||
streaming.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1713,6 +1724,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritten_streaming_field_streams() {
|
||||
assert!(streaming_requested(None));
|
||||
assert!(streaming_requested(Some(true)));
|
||||
assert!(!streaming_requested(Some(false)));
|
||||
}
|
||||
|
||||
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
|
||||
/// trip per run and silently leaves that step with no prompt caching at all.
|
||||
#[test]
|
||||
|
||||
@@ -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({
|
||||
|
||||
+27
-11
@@ -24,6 +24,7 @@ import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
|
||||
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
|
||||
|
||||
export interface AppFile {
|
||||
guests?: boolean;
|
||||
value: any;
|
||||
public?: boolean;
|
||||
summary: string;
|
||||
@@ -110,6 +111,28 @@ export function replaceInlineScripts(
|
||||
export function isExecutionModeAnonymous(app: any) {
|
||||
return app?.["policy"]?.["execution_mode"] == "anonymous";
|
||||
}
|
||||
export function isExecutionModeGuest(app: any) {
|
||||
return app?.["policy"]?.["execution_mode"] == "guest";
|
||||
}
|
||||
export type AppExecutionMode = "anonymous" | "guest" | "publisher";
|
||||
/** The access mode is the one policy field a tracked app keeps, as `public` (anonymous)
|
||||
* or `guests` (guest); the rest of the policy is regenerated on push. */
|
||||
export function markAccessFromPolicy(app: any) {
|
||||
if (isExecutionModeAnonymous(app)) {
|
||||
app.public = true;
|
||||
} else if (isExecutionModeGuest(app)) {
|
||||
app.guests = true;
|
||||
}
|
||||
}
|
||||
export function executionModeFromAppFile(app: any): AppExecutionMode {
|
||||
if (app?.["public"] ?? isExecutionModeAnonymous(app)) {
|
||||
return "anonymous";
|
||||
}
|
||||
if (app?.["guests"] ?? isExecutionModeGuest(app)) {
|
||||
return "guest";
|
||||
}
|
||||
return "publisher";
|
||||
}
|
||||
export async function pushApp(
|
||||
workspace: string,
|
||||
remotePath: string,
|
||||
@@ -140,9 +163,7 @@ export async function pushApp(
|
||||
remoteOnBehalfOfEmail = app.policy.on_behalf_of_email;
|
||||
}
|
||||
|
||||
if (isExecutionModeAnonymous(app)) {
|
||||
app.public = true;
|
||||
}
|
||||
markAccessFromPolicy(app);
|
||||
// console.log(app);
|
||||
if (app) {
|
||||
app.policy = undefined;
|
||||
@@ -155,12 +176,7 @@ export async function pushApp(
|
||||
const localApp = (await yamlParseFile(path)) as AppFile;
|
||||
|
||||
replaceInlineScripts(localApp.value, localPath, true);
|
||||
await generatingPolicy(
|
||||
localApp,
|
||||
remotePath,
|
||||
localApp?.["public"] ??
|
||||
localApp?.["policy"]?.["execution_mode"] == "anonymous"
|
||||
);
|
||||
await generatingPolicy(localApp, remotePath, executionModeFromAppFile(localApp));
|
||||
|
||||
const preserveFields: { preserve_on_behalf_of?: boolean } = {};
|
||||
if (permissionedAsContext?.userIsAdminOrDeployer) {
|
||||
@@ -230,12 +246,12 @@ export async function pushApp(
|
||||
export async function generatingPolicy(
|
||||
app: any,
|
||||
path: string,
|
||||
publicApp: boolean
|
||||
executionMode: AppExecutionMode
|
||||
) {
|
||||
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
|
||||
try {
|
||||
app.policy = await windmillUtils.updatePolicy(app.value, undefined);
|
||||
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
|
||||
app.policy.execution_mode = executionMode;
|
||||
} catch (e) {
|
||||
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
|
||||
throw e;
|
||||
|
||||
@@ -15,7 +15,13 @@ import { readdir } from "node:fs/promises";
|
||||
import { GlobalOptions, isSuperset } from "../../types.ts";
|
||||
import { deepEqual, readTextFile } from "../../utils/utils.ts";
|
||||
|
||||
import { replaceInlineScripts, repopulateFields } from "./app.ts";
|
||||
import {
|
||||
type AppExecutionMode,
|
||||
executionModeFromAppFile,
|
||||
markAccessFromPolicy,
|
||||
replaceInlineScripts,
|
||||
repopulateFields,
|
||||
} from "./app.ts";
|
||||
import { createBundle, detectFrameworks } from "./bundle.ts";
|
||||
import { APP_BACKEND_FOLDER, RECORDINGS_FOLDER } from "./app_metadata.ts";
|
||||
import { writeIfChanged } from "../../utils/utils.ts";
|
||||
@@ -27,6 +33,7 @@ import {
|
||||
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
|
||||
|
||||
export interface AppFile {
|
||||
guests?: boolean;
|
||||
runnables?: any;
|
||||
custom_path?: string;
|
||||
public?: boolean;
|
||||
@@ -369,9 +376,7 @@ export async function pushRawApp(
|
||||
} catch {
|
||||
//ignore
|
||||
}
|
||||
if (app?.["policy"]?.["execution_mode"] == "anonymous") {
|
||||
app.public = true;
|
||||
}
|
||||
markAccessFromPolicy(app);
|
||||
// console.log(app);
|
||||
if (app) {
|
||||
app.policy = undefined;
|
||||
@@ -422,7 +427,7 @@ export async function pushRawApp(
|
||||
await generatingPolicy(
|
||||
appForPolicy,
|
||||
remotePath,
|
||||
localApp?.["public"] ?? false,
|
||||
executionModeFromAppFile(localApp),
|
||||
);
|
||||
|
||||
const files = await collectAppFiles(localPath);
|
||||
@@ -526,7 +531,7 @@ export async function pushRawApp(
|
||||
export async function generatingPolicy(
|
||||
app: any,
|
||||
path: string,
|
||||
publicApp: boolean,
|
||||
executionMode: AppExecutionMode,
|
||||
) {
|
||||
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
|
||||
try {
|
||||
@@ -534,7 +539,7 @@ export async function generatingPolicy(
|
||||
app.runnables,
|
||||
app.policy,
|
||||
);
|
||||
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
|
||||
app.policy.execution_mode = executionMode;
|
||||
} catch (e) {
|
||||
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
|
||||
throw e;
|
||||
|
||||
@@ -142,7 +142,7 @@ import {
|
||||
extractCurrentMapping,
|
||||
} from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
|
||||
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
|
||||
import { isExecutionModeAnonymous } from "../app/app.ts";
|
||||
import { markAccessFromPolicy } from "../app/app.ts";
|
||||
import {
|
||||
APP_BACKEND_FOLDER,
|
||||
generateAppLocksInternal,
|
||||
@@ -1393,9 +1393,7 @@ export function ZipFSElement(
|
||||
};
|
||||
}
|
||||
|
||||
if (isExecutionModeAnonymous(app)) {
|
||||
app.public = true;
|
||||
}
|
||||
markAccessFromPolicy(app);
|
||||
app.policy = undefined;
|
||||
yield {
|
||||
isDirectory: false,
|
||||
@@ -1413,9 +1411,7 @@ export function ZipFSElement(
|
||||
log.error(`Failed to parse app.yaml at path: ${p}`);
|
||||
throw error;
|
||||
}
|
||||
if (rawApp?.["policy"]?.["execution_mode"] == "anonymous") {
|
||||
rawApp.public = true;
|
||||
}
|
||||
markAccessFromPolicy(rawApp);
|
||||
// console.log("rawApp", rawApp);
|
||||
rawApp.policy = undefined;
|
||||
// custom_path is derived from the file path, don't store it
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
executionModeFromAppFile,
|
||||
generatingPolicy,
|
||||
markAccessFromPolicy,
|
||||
} from "../src/commands/app/app.ts";
|
||||
|
||||
// The access mode is the one policy field a tracked app keeps; a pull then a push must
|
||||
// deploy the mode that was pulled, guest included, not a default.
|
||||
test("the access mode survives the app.yaml round trip", async () => {
|
||||
const guest: any = { policy: { execution_mode: "guest" }, value: {} };
|
||||
markAccessFromPolicy(guest);
|
||||
guest.policy = undefined;
|
||||
expect(guest.guests).toBe(true);
|
||||
expect(guest.public).toBeUndefined();
|
||||
expect(executionModeFromAppFile(guest)).toBe("guest");
|
||||
await generatingPolicy(guest, "u/test/app", executionModeFromAppFile(guest));
|
||||
expect(guest.policy.execution_mode).toBe("guest");
|
||||
|
||||
const anonymous: any = { policy: { execution_mode: "anonymous" }, value: {} };
|
||||
markAccessFromPolicy(anonymous);
|
||||
anonymous.policy = undefined;
|
||||
expect(anonymous.public).toBe(true);
|
||||
expect(executionModeFromAppFile(anonymous)).toBe("anonymous");
|
||||
|
||||
expect(executionModeFromAppFile({ policy: { execution_mode: "publisher" } })).toBe("publisher");
|
||||
expect(executionModeFromAppFile({})).toBe("publisher");
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface SchemaProperty {
|
||||
}
|
||||
min?: number
|
||||
max?: number
|
||||
/** Height a string field's text area opens at, in rows. */
|
||||
minRows?: number
|
||||
currency?: string
|
||||
currencyLocale?: string
|
||||
multiselect?: boolean
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user