diff --git a/.github/actions/sign-attest-image/action.yml b/.github/actions/sign-attest-image/action.yml new file mode 100644 index 0000000000..de0133dac0 --- /dev/null +++ b/.github/actions/sign-attest-image/action.yml @@ -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-.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 diff --git a/.github/workflows/build_cli_image.yml b/.github/workflows/build_cli_image.yml index 5b71a786aa..de64a76583 100644 --- a/.github/workflows/build_cli_image.yml +++ b/.github/workflows/build_cli_image.yml @@ -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 }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 433f3a86b3..a8b20623bc 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -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 }} diff --git a/.github/workflows/publish_extra.yml b/.github/workflows/publish_extra.yml index 49964c95d9..ca62933292 100644 --- a/.github/workflows/publish_extra.yml +++ b/.github/workflows/publish_extra.yml @@ -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 }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 249f8350ae..5dc2004c6f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.803.0" + ".": "1.804.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e24c0c00..4acfb0edad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/.sqlx/query-0411a67eb9d88244fa654eda51123e16b5931c08c1073b09ab01dad205f161ed.json b/backend/.sqlx/query-0411a67eb9d88244fa654eda51123e16b5931c08c1073b09ab01dad205f161ed.json new file mode 100644 index 0000000000..67f2bbbe81 --- /dev/null +++ b/backend/.sqlx/query-0411a67eb9d88244fa654eda51123e16b5931c08c1073b09ab01dad205f161ed.json @@ -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" +} diff --git a/backend/.sqlx/query-06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac.json b/backend/.sqlx/query-06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac.json new file mode 100644 index 0000000000..843207dad7 --- /dev/null +++ b/backend/.sqlx/query-06af616fe4fc61a3b0dcd996a2a1e0e9ac63fc8756aeafac8d279841db117eac.json @@ -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" +} diff --git a/backend/.sqlx/query-0e277240d2be50383ac53d844c71369067c41870be4561e1c26733ac30419801.json b/backend/.sqlx/query-0e277240d2be50383ac53d844c71369067c41870be4561e1c26733ac30419801.json new file mode 100644 index 0000000000..359a7eeefd --- /dev/null +++ b/backend/.sqlx/query-0e277240d2be50383ac53d844c71369067c41870be4561e1c26733ac30419801.json @@ -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" +} diff --git a/backend/.sqlx/query-0fc900f73ef119e4c89186cf120938a298bfe29afe1fd7111340252877f8b86c.json b/backend/.sqlx/query-0fc900f73ef119e4c89186cf120938a298bfe29afe1fd7111340252877f8b86c.json new file mode 100644 index 0000000000..abdea8c942 --- /dev/null +++ b/backend/.sqlx/query-0fc900f73ef119e4c89186cf120938a298bfe29afe1fd7111340252877f8b86c.json @@ -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" +} diff --git a/backend/.sqlx/query-10af387fce25f6ea7af275e8e93b7ab1f2fc29a2ba79a39576551bdf66b592b6.json b/backend/.sqlx/query-10af387fce25f6ea7af275e8e93b7ab1f2fc29a2ba79a39576551bdf66b592b6.json deleted file mode 100644 index 3f39982319..0000000000 --- a/backend/.sqlx/query-10af387fce25f6ea7af275e8e93b7ab1f2fc29a2ba79a39576551bdf66b592b6.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1.json b/backend/.sqlx/query-1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1.json new file mode 100644 index 0000000000..3e4b85a916 --- /dev/null +++ b/backend/.sqlx/query-1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1.json @@ -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" +} diff --git a/backend/.sqlx/query-209c96d522f9683b39f053707568f9943111e0bac7d4fb478300f0cd8b799f23.json b/backend/.sqlx/query-209c96d522f9683b39f053707568f9943111e0bac7d4fb478300f0cd8b799f23.json new file mode 100644 index 0000000000..56128dab99 --- /dev/null +++ b/backend/.sqlx/query-209c96d522f9683b39f053707568f9943111e0bac7d4fb478300f0cd8b799f23.json @@ -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" +} diff --git a/backend/.sqlx/query-2105d37be923a445933c899bb31523709f837b7e1969b7364f70cc2a60cdd520.json b/backend/.sqlx/query-2105d37be923a445933c899bb31523709f837b7e1969b7364f70cc2a60cdd520.json new file mode 100644 index 0000000000..c1ff273bcb --- /dev/null +++ b/backend/.sqlx/query-2105d37be923a445933c899bb31523709f837b7e1969b7364f70cc2a60cdd520.json @@ -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" +} diff --git a/backend/.sqlx/query-21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe.json b/backend/.sqlx/query-21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe.json new file mode 100644 index 0000000000..ca9f2cbc29 --- /dev/null +++ b/backend/.sqlx/query-21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe.json @@ -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" +} diff --git a/backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json b/backend/.sqlx/query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json similarity index 54% rename from backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json rename to backend/.sqlx/query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json index 774b47f825..556bd7c317 100644 --- a/backend/.sqlx/query-c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5.json +++ b/backend/.sqlx/query-31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1.json @@ -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" } diff --git a/backend/.sqlx/query-391139a04bd48319a5512e7859b63e81438c7483ac892b971fe8a20709555cc1.json b/backend/.sqlx/query-391139a04bd48319a5512e7859b63e81438c7483ac892b971fe8a20709555cc1.json new file mode 100644 index 0000000000..8fcb8140f4 --- /dev/null +++ b/backend/.sqlx/query-391139a04bd48319a5512e7859b63e81438c7483ac892b971fe8a20709555cc1.json @@ -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" +} diff --git a/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json b/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json new file mode 100644 index 0000000000..ebd492abf3 --- /dev/null +++ b/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json @@ -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" +} diff --git a/backend/.sqlx/query-0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007.json b/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json similarity index 78% rename from backend/.sqlx/query-0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007.json rename to backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json index e74bd5de0e..f504d7a809 100644 --- a/backend/.sqlx/query-0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007.json +++ b/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json @@ -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" } diff --git a/backend/.sqlx/query-9b727f03e74ea4a35146c9a339cda82104a9ee39bfbe5d932340a3c2c209c5d0.json b/backend/.sqlx/query-9b727f03e74ea4a35146c9a339cda82104a9ee39bfbe5d932340a3c2c209c5d0.json new file mode 100644 index 0000000000..30216f04e9 --- /dev/null +++ b/backend/.sqlx/query-9b727f03e74ea4a35146c9a339cda82104a9ee39bfbe5d932340a3c2c209c5d0.json @@ -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" +} diff --git a/backend/.sqlx/query-a684f160d1a366c1928fef27c613e6e08f808f423c8f2d58b9c849aba7d176f5.json b/backend/.sqlx/query-a684f160d1a366c1928fef27c613e6e08f808f423c8f2d58b9c849aba7d176f5.json deleted file mode 100644 index 7a45e6c402..0000000000 --- a/backend/.sqlx/query-a684f160d1a366c1928fef27c613e6e08f808f423c8f2d58b9c849aba7d176f5.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2.json b/backend/.sqlx/query-cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2.json new file mode 100644 index 0000000000..9ff8b3ace5 --- /dev/null +++ b/backend/.sqlx/query-cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2" +} diff --git a/backend/.sqlx/query-a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726.json b/backend/.sqlx/query-d17645b5001d7f8da1dc451c5d35ea3c9346271b8404863256071cfdf884036a.json similarity index 65% rename from backend/.sqlx/query-a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726.json rename to backend/.sqlx/query-d17645b5001d7f8da1dc451c5d35ea3c9346271b8404863256071cfdf884036a.json index d507609d8e..c3134373f6 100644 --- a/backend/.sqlx/query-a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726.json +++ b/backend/.sqlx/query-d17645b5001d7f8da1dc451c5d35ea3c9346271b8404863256071cfdf884036a.json @@ -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" } diff --git a/backend/.sqlx/query-d3a6a27ece3b5d5071dd8074b8db6f369eceb079b31ca118cae23c3d15314590.json b/backend/.sqlx/query-d3a6a27ece3b5d5071dd8074b8db6f369eceb079b31ca118cae23c3d15314590.json new file mode 100644 index 0000000000..ad5a3c9f56 --- /dev/null +++ b/backend/.sqlx/query-d3a6a27ece3b5d5071dd8074b8db6f369eceb079b31ca118cae23c3d15314590.json @@ -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" +} diff --git a/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json b/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json new file mode 100644 index 0000000000..01c0fd19af --- /dev/null +++ b/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json @@ -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" +} diff --git a/backend/.sqlx/query-e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd.json b/backend/.sqlx/query-e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd.json new file mode 100644 index 0000000000..ae893b6791 --- /dev/null +++ b/backend/.sqlx/query-e12f852fa196d16862151ab490b05e6f5c25e23b7c41ce1c0e5a83bf7b118bfd.json @@ -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" +} diff --git a/backend/.sqlx/query-e749663a4b9248a9d120f77e86fd3413ad3c0fbe8f3dca9aea11cb49fd4a28ef.json b/backend/.sqlx/query-e749663a4b9248a9d120f77e86fd3413ad3c0fbe8f3dca9aea11cb49fd4a28ef.json new file mode 100644 index 0000000000..328465fdf1 --- /dev/null +++ b/backend/.sqlx/query-e749663a4b9248a9d120f77e86fd3413ad3c0fbe8f3dca9aea11cb49fd4a28ef.json @@ -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" +} diff --git a/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json b/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json new file mode 100644 index 0000000000..1c247ad5b5 --- /dev/null +++ b/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json @@ -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" +} diff --git a/backend/.sqlx/query-f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed.json b/backend/.sqlx/query-f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed.json new file mode 100644 index 0000000000..5f86fb97d8 --- /dev/null +++ b/backend/.sqlx/query-f0498c9bddc0d4d8948167f3ed849bf8b3523d8dde1ef6b8f1950423830cc6ed.json @@ -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" +} diff --git a/backend/.sqlx/query-f56c58fea9f27d2e55d33720e032808e90cb068d1048717f82992f476377cc20.json b/backend/.sqlx/query-f56c58fea9f27d2e55d33720e032808e90cb068d1048717f82992f476377cc20.json deleted file mode 100644 index 72acab6120..0000000000 --- a/backend/.sqlx/query-f56c58fea9f27d2e55d33720e032808e90cb068d1048717f82992f476377cc20.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-f6fe63ef3518d2d1f321c2941bc59da15843f466c72159bf76712b124d7554b6.json b/backend/.sqlx/query-f6fe63ef3518d2d1f321c2941bc59da15843f466c72159bf76712b124d7554b6.json new file mode 100644 index 0000000000..d9b9aabf58 --- /dev/null +++ b/backend/.sqlx/query-f6fe63ef3518d2d1f321c2941bc59da15843f466c72159bf76712b124d7554b6.json @@ -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" +} diff --git a/backend/.sqlx/query-fbee9545c564f6fd611ca1a122cf420b03fd23304f78c382d6de14cb31bcd6b1.json b/backend/.sqlx/query-fbee9545c564f6fd611ca1a122cf420b03fd23304f78c382d6de14cb31bcd6b1.json new file mode 100644 index 0000000000..20b5c53392 --- /dev/null +++ b/backend/.sqlx/query-fbee9545c564f6fd611ca1a122cf420b03fd23304f78c382d6de14cb31bcd6b1.json @@ -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" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d314168b35..e4dc875e49 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -430,7 +430,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "indexmap 2.14.1", + "indexmap 2.14.2", "lexical-core", "memchr", "num", @@ -781,7 +781,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-util", "tokio-websockets", "tracing", @@ -873,7 +873,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1348,7 +1348,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tower 0.5.3", "tracing", ] @@ -1976,7 +1976,7 @@ dependencies = [ "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -2000,7 +2000,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -2144,7 +2144,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -2311,9 +2311,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -2454,7 +2454,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -3047,7 +3047,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -3102,7 +3102,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -3247,7 +3247,7 @@ dependencies = [ "base64 0.22.1", "half", "hashbrown 0.14.5", - "indexmap 2.14.1", + "indexmap 2.14.2", "libc", "log", "object_store", @@ -3426,7 +3426,7 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap 2.14.1", + "indexmap 2.14.2", "paste", "recursive", "serde_json", @@ -3441,7 +3441,7 @@ checksum = "422ac9cf3b22bbbae8cdf8ceb33039107fde1b5492693168f13bd566b1bcc839" dependencies = [ "arrow", "datafusion-common", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "paste", ] @@ -3595,7 +3595,7 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-physical-expr", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "log", "recursive", @@ -3618,7 +3618,7 @@ dependencies = [ "datafusion-physical-expr-common", "half", "hashbrown 0.14.5", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "log", "paste", @@ -3680,7 +3680,7 @@ dependencies = [ "futures", "half", "hashbrown 0.14.5", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "log", "parking_lot", @@ -3722,7 +3722,7 @@ dependencies = [ "bigdecimal", "datafusion-common", "datafusion-expr", - "indexmap 2.14.1", + "indexmap 2.14.2", "log", "recursive", "regex", @@ -3853,7 +3853,7 @@ dependencies = [ "deno_path_util", "deno_unsync", "futures", - "indexmap 2.14.1", + "indexmap 2.14.2", "libc", "parking_lot", "percent-encoding", @@ -4005,7 +4005,7 @@ dependencies = [ "serde_json", "thiserror 2.0.20", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-socks", "tokio-util", "tokio-vsock", @@ -4122,7 +4122,7 @@ version = "0.228.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf8dbe5abf37d270bb853c5dfe45fbe3b1b6c453877cc11d7fe84e9862a6dbc" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "proc-macro-rules", "proc-macro2", "quote", @@ -4603,7 +4603,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4850,7 +4850,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -5055,9 +5055,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -5177,9 +5177,9 @@ dependencies = [ [[package]] name = "frostem" -version = "1.20260821.4" +version = "1.20260821.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "481ace7f781f5ae54a5c0a6d6d8edb30adba737cfa1230fbd5632d63ba8dfd80" +checksum = "36a80a7406da302e04bfd2ca987907590d3a1f3c69958947c43890abd7426b2f" [[package]] name = "fs3" @@ -5319,7 +5319,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -5797,7 +5797,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.14.1", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -5816,7 +5816,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.5.0", - "indexmap 2.14.1", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -5898,7 +5898,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "proc-macro2", "quote", "syn 2.0.119", @@ -6262,7 +6262,7 @@ dependencies = [ "hyper-util", "pin-project-lite", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tower-service", ] @@ -6328,7 +6328,7 @@ dependencies = [ "rustls 0.23.35", "rustls-native-certs 0.8.4", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tower-service", "webpki-roots 1.0.9", ] @@ -6570,9 +6570,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -6857,9 +6857,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -6958,7 +6958,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", ] [[package]] @@ -7269,7 +7269,7 @@ dependencies = [ "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.9.3", + "redox_syscall 0.9.4", ] [[package]] @@ -7516,7 +7516,7 @@ dependencies = [ "rustls-pki-types", "smtp-proto", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "webpki-roots 0.26.11", ] @@ -7741,9 +7741,9 @@ dependencies = [ [[package]] name = "minicov" -version = "0.3.9" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" dependencies = [ "cc", "walkdir", @@ -8125,7 +8125,7 @@ dependencies = [ "dirs-sys 0.4.1", "fancy-regex 0.14.0", "heck", - "indexmap 2.14.1", + "indexmap 2.14.2", "log", "lru 0.12.5", "miette", @@ -9124,7 +9124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.14.1", + "indexmap 2.14.2", ] [[package]] @@ -9416,9 +9416,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -9526,7 +9526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -9620,7 +9620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.14.1", + "indexmap 2.14.2", "nix 0.30.1", "tokio", "tracing", @@ -10167,9 +10167,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ "bitflags 2.13.1", ] @@ -10213,7 +10213,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -10311,7 +10311,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-util", "tower 0.5.3", "tower-http", @@ -10357,7 +10357,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-util", "tower 0.5.3", "tower-http", @@ -10532,7 +10532,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -10566,7 +10566,7 @@ dependencies = [ "convert_case 0.10.0", "fnv", "ident_case", - "indexmap 2.14.1", + "indexmap 2.14.2", "proc-macro-crate", "proc-macro2", "quote", @@ -11169,7 +11169,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.30.0", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -11368,7 +11368,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -11390,7 +11390,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -11399,7 +11399,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -11444,7 +11444,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -11493,7 +11493,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.1", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -11521,7 +11521,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "itoa", "ryu", "serde", @@ -11534,7 +11534,7 @@ version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "itoa", "libyml", "memchr", @@ -11954,7 +11954,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.14.1", + "indexmap 2.14.2", "log", "memchr", "once_cell", @@ -12120,9 +12120,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" dependencies = [ "bytes", "futures-util", @@ -12311,7 +12311,7 @@ checksum = "72e90b52ee734ded867104612218101722ad87ff4cf74fe30383bd244a533f97" dependencies = [ "anyhow", "bytes-str", - "indexmap 2.14.1", + "indexmap 2.14.2", "serde", "serde_json", "swc_config_macro", @@ -12445,7 +12445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c6f1b8f4232e7a7f614ff7c0f6ccb89c2d028cdf7629f79ad710cff5b28b62c" dependencies = [ "better_scoped_tls", - "indexmap 2.14.1", + "indexmap 2.14.2", "once_cell", "par-core", "phf 0.11.3", @@ -12511,7 +12511,7 @@ checksum = "69ea0052ac23b5b9fbc85bbdb1791b36b918f9d55f594b0ed8e25babb4c32d16" dependencies = [ "base64 0.22.1", "bytes-str", - "indexmap 2.14.1", + "indexmap 2.14.2", "once_cell", "rustc-hash 2.1.3", "serde", @@ -12551,7 +12551,7 @@ version = "21.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83259addd99ed4022aa9fc4d39428c008d3d42533769e1a005529da18cde4568" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "num_cpus", "once_cell", "par-core", @@ -12660,9 +12660,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -13056,7 +13056,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -13202,9 +13202,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -13401,9 +13401,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls 0.23.35", "tokio", @@ -13510,7 +13510,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-util", ] @@ -13550,7 +13550,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -13563,7 +13563,7 @@ version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.1", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -13603,7 +13603,7 @@ dependencies = [ "rustls-pemfile 2.2.0", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-stream", "tower 0.4.13", "tower-layer", @@ -13635,7 +13635,7 @@ dependencies = [ "rustls-native-certs 0.8.4", "socket2 0.5.10", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-stream", "tower 0.5.3", "tower-layer", @@ -13671,7 +13671,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.1", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -14022,7 +14022,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -14458,9 +14458,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -14472,9 +14472,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -14482,9 +14482,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -14492,31 +14492,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" +checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db" dependencies = [ "async-trait", "cast", @@ -14536,20 +14536,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" +checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" +checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af" [[package]] name = "wasm-streams" @@ -14603,9 +14603,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-nats", @@ -14764,6 +14764,7 @@ dependencies = [ "git-version", "hex", "hmac", + "jsonwebtoken 8.3.0", "lazy_static", "once_cell", "opentelemetry 0.30.0", @@ -14791,6 +14792,7 @@ dependencies = [ "tikv-jemallocator", "tokio", "tokio-stream", + "tower-cookies", "tracing", "tracing-subscriber", "url", @@ -14802,6 +14804,7 @@ dependencies = [ "windmill-api-client", "windmill-api-scripts", "windmill-api-settings", + "windmill-api-users", "windmill-autoscaling", "windmill-common", "windmill-dep-map", @@ -14832,7 +14835,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.803.0" +version = "1.804.0" dependencies = [ "async-stream", "async-trait", @@ -14865,7 +14868,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14878,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "argon2", @@ -14909,7 +14912,7 @@ dependencies = [ "hmac", "http 1.5.0", "hyper 1.11.1", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -15018,7 +15021,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15041,7 +15044,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15058,7 +15061,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15084,7 +15087,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.803.0" +version = "1.804.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15094,7 +15097,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15111,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15133,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15156,7 +15159,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15172,7 +15175,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15194,7 +15197,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15215,7 +15218,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15229,7 +15232,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-nats", @@ -15264,7 +15267,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15289,7 +15292,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,12 +15320,12 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "axum 0.8.9", "http 1.5.0", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "lazy_static", "serde", @@ -15339,7 +15342,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15359,7 +15362,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15397,7 +15400,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15425,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.803.0" +version = "1.804.0" dependencies = [ "lazy_static", "serde", @@ -15437,7 +15440,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.803.0" +version = "1.804.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15461,7 +15464,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15475,7 +15478,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.803.0" +version = "1.804.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15510,7 +15513,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.803.0" +version = "1.804.0" dependencies = [ "chrono", "lazy_static", @@ -15524,7 +15527,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15543,7 +15546,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.803.0" +version = "1.804.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15581,7 +15584,7 @@ dependencies = [ "hex", "hmac", "hyper 1.11.1", - "indexmap 2.14.1", + "indexmap 2.14.2", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -15599,6 +15602,7 @@ dependencies = [ "pep440_rs", "phf 0.11.3", "pin-project-lite", + "pkcs1", "postgres-native-tls 0.5.3", "prometheus", "quick_cache", @@ -15615,6 +15619,7 @@ dependencies = [ "serde_yml", "sha2 0.10.9", "size", + "spki", "sqlx", "strum", "strum_macros", @@ -15647,7 +15652,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.803.0" +version = "1.804.0" dependencies = [ "chrono", "futures", @@ -15667,7 +15672,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.803.0" +version = "1.804.0" dependencies = [ "regex", "serde", @@ -15682,7 +15687,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15709,7 +15714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "futures", @@ -15726,7 +15731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.803.0" +version = "1.804.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15742,7 +15747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -15763,7 +15768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -15794,7 +15799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "arc-swap", @@ -15819,7 +15824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-stream", @@ -15853,7 +15858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "futures", @@ -15871,7 +15876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.803.0" +version = "1.804.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15880,7 +15885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "lazy_static", @@ -15892,7 +15897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "serde_json", @@ -15904,7 +15909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "gosyn", @@ -15916,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "lazy_static", @@ -15928,7 +15933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "serde_json", @@ -15940,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "nu-parser", @@ -15951,7 +15956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15962,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15974,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15985,7 +15990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-recursion", @@ -16007,7 +16012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "serde_json", @@ -16019,7 +16024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "lazy_static", @@ -16033,7 +16038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16050,7 +16055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "lazy_static", @@ -16063,7 +16068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "serde", @@ -16075,7 +16080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "lazy_static", @@ -16093,7 +16098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16109,7 +16114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16125,7 +16130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "lazy_static", @@ -16139,7 +16144,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-recursion", @@ -16178,7 +16183,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "const_format", @@ -16218,7 +16223,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.803.0" +version = "1.804.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16229,7 +16234,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-recursion", @@ -16264,7 +16269,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16288,7 +16293,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16321,7 +16326,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16348,7 +16353,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16381,7 +16386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16401,7 +16406,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16435,7 +16440,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16471,7 +16476,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16494,7 +16499,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16518,7 +16523,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-nats", @@ -16542,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16577,7 +16582,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16605,7 +16610,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-trait", @@ -16630,7 +16635,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16649,7 +16654,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.803.0" +version = "1.804.0" dependencies = [ "anyhow", "async-once-cell", @@ -16721,7 +16726,7 @@ dependencies = [ "tiberius", "tokio", "tokio-postgres", - "tokio-rustls 0.26.4", + "tokio-rustls 0.26.5", "tokio-stream", "tokio-util", "tracing", @@ -16766,7 +16771,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.803.0" +version = "1.804.0" dependencies = [ "bytes", "futures", @@ -17555,7 +17560,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -17565,7 +17570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", - "indexmap 2.14.1", + "indexmap 2.14.2", "memchr", "typed-path", ] @@ -17593,18 +17598,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f432f022c5..7abb3b92ee 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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 "] 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" diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index f5725d65a3..af33877078 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -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 | diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index dc5fe7ce82..0ebbb1b5ea 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b9aeffa83f0e601f123c7eab536b235719786da1 +d33ea730c550cdbc7d050aeb6d40dcef3d134e07 diff --git a/backend/migrations/20260901192755_guest_app_access.down.sql b/backend/migrations/20260901192755_guest_app_access.down.sql new file mode 100644 index 0000000000..065e60c0c8 --- /dev/null +++ b/backend/migrations/20260901192755_guest_app_access.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS guest_activity; +ALTER TABLE workspace_settings DROP COLUMN guest_access_enabled; diff --git a/backend/migrations/20260901192755_guest_app_access.up.sql b/backend/migrations/20260901192755_guest_app_access.up.sql new file mode 100644 index 0000000000..a29582ba99 --- /dev/null +++ b/backend/migrations/20260901192755_guest_app_access.up.sql @@ -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); diff --git a/backend/migrations/20260903071242_guest_jwt_entry.down.sql b/backend/migrations/20260903071242_guest_jwt_entry.down.sql new file mode 100644 index 0000000000..f3e758819b --- /dev/null +++ b/backend/migrations/20260903071242_guest_jwt_entry.down.sql @@ -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; diff --git a/backend/migrations/20260903071242_guest_jwt_entry.up.sql b/backend/migrations/20260903071242_guest_jwt_entry.up.sql new file mode 100644 index 0000000000..0cc449f539 --- /dev/null +++ b/backend/migrations/20260903071242_guest_jwt_entry.up.sql @@ -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; diff --git a/backend/oauth_login.json b/backend/oauth_login.json index 93cdaac2b2..4706429404 100644 --- a/backend/oauth_login.json +++ b/backend/oauth_login.json @@ -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", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 395e692623..9ed900214c 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -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", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 45c8b0d94e..cbb7102820 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.803.0" +version = "1.804.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 86e75b7de9..a11ed55882 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -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", ) diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index dfd0305e57..61f8a66f85 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -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) diff --git a/backend/tests/app_guest_allowance.rs b/backend/tests/app_guest_allowance.rs new file mode 100644 index 0000000000..88626b1c81 --- /dev/null +++ b/backend/tests/app_guest_allowance.rs @@ -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, email: &str) -> windmill_common::error::Result { + 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, +) -> 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(()) +} diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs new file mode 100644 index 0000000000..7af2e0794e --- /dev/null +++ b/backend/tests/app_guest_execution_mode.rs @@ -0,0 +1,1092 @@ +//! Tests for the `guest` app execution mode. +//! +//! A guest (`ExecutionMode::Guest`) has no account and so no ACL of its own: its +//! token's scopes are its entire grant. These tests pin the three things that would +//! silently undo it: +//! +//! * what makes a token a guest — the server-minted label, never a scope anyone +//! could type into `users/tokens/create`; +//! * the confinement — a guest reaches the one app it was let in for and nothing +//! else; +//! * the switches — an app's own `execution_mode: guest` is inert unless the +//! workspace and the instance allow guests, checked at the door rather than only +//! where a policy is written (git-sync and the CLI push policies past every UI); +//! the allowance on top of them has a binary of its own. +//! +//! The token is inserted directly: how a guest session is minted is the identity +//! provider's business (EE), what one can do is this file's. +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const GUEST_TOKEN: &str = "GUEST_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)) +} + +async fn enable_guests(port: u16, ws: &str) -> anyhow::Result<()> { + authed( + client().post(format!( + "http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access" + )), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": true })) + .send() + .await?; + Ok(()) +} + +fn guest_scopes() -> Vec { + vec![ + "guest".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}"), + ] +} + +/// Insert a guest session for `test-workspace`, scoped to `APP_PATH`. Mirrors +/// `create_guest_session_token`: the server-minted label, the narrow reads, the two +/// path-scoped app grants, the workspace pin, and an expiry — a derived token's +/// lifetime is capped at it, so a guest session without one cannot mint. +async fn insert_guest_token(db: &Pool, workspace: &str) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, scopes, workspace_id, expiration) + VALUES (encode(sha256($1::bytea), 'hex'), 'GUEST_SECR', $2, 'guest@example.com', + 'guest_session', $3, $4, now() + interval '8 hours')", + ) + .bind(GUEST_TOKEN.as_bytes()) + .bind(GUEST_TOKEN) + .bind(guest_scopes()) + .bind(workspace) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn guest_session_is_confined_to_its_app(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + insert_guest_token(&db, "test-workspace").await?; + + // Its own identity resolves, and reports the role rather than falling through to + // the non-member branch that hands out a `superadmin` shape. + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!(resp.status(), 200, "guest whoami must resolve"); + let me: serde_json::Value = resp.json().await?; + assert_eq!( + me["role"], + json!("guest"), + "guest must not read as superadmin" + ); + assert_eq!(me["operator"], json!(true)); + assert_eq!(me["is_admin"], json!(false)); + + // `resources/list_names` and the type schemas stay open — a guest drives an app, + // and app pickers need them — so the line to pin is the value-returning route. + for route in [ + "jobs/list", + "scripts/list", + "flows/list", + "variables/list", + "resources/get_value/u/test-user/secret", + "apps/list", + ] { + let resp = authed(client().get(format!("{ws}/{route}")), GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "guest must be denied {route}, got {}", + resp.status() + ); + } + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn guest_token_does_not_cross_workspaces(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // A second workspace with an app at the SAME path: without the token's workspace + // pin, `apps:run:` would unlock it too, since a path is not unique across + // workspaces. + sqlx::query( + "INSERT INTO workspace (id, name, owner) VALUES ('other-ws', 'other-ws', 'test-user')", + ) + .execute(&db) + .await?; + sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other-ws')") + .execute(&db) + .await?; + + insert_guest_token(&db, "test-workspace").await?; + + let resp = authed( + client().get(format!( + "http://localhost:{port}/api/w/other-ws/apps/get/p/{APP_PATH}" + )), + GUEST_TOKEN, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "a guest token pinned to one workspace must not authenticate against another" + ); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch( + db: Pool, +) -> 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"); + + 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": {} } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + let secret: String = authed( + client().get(format!("{ws}/apps/secret_of/{APP_PATH}")), + ADMIN_TOKEN, + ) + .send() + .await? + .text() + .await?; + + // The app says guest, the workspace has not opted in: inert. + let resp = client() + .get(format!("{ws}/apps_u/guest_entry/{secret}")) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "a guest app in a workspace that has not enabled guests must not advertise entry" + ); + + authed( + client().post(format!("{ws}/workspaces/edit_guest_access")), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": true })) + .send() + .await?; + + // Unauthenticated on purpose: this is what a signed-out visitor reads. + let resp = client() + .get(format!("{ws}/apps_u/guest_entry/{secret}")) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let entry: serde_json::Value = resp.json().await?; + assert_eq!(entry["app_path"], json!(APP_PATH)); + + // Turning the switch back off closes the door again even though the app's own + // policy is unchanged. + authed( + client().post(format!("{ws}/workspaces/edit_guest_access")), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": false })) + .send() + .await?; + let resp = client() + .get(format!("{ws}/apps_u/guest_entry/{secret}")) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "turning guests off must stop advertising entry for an app already set to guest" + ); + + Ok(()) +} + +/// The guest grant is the server-minted label, never the `guest` scope. Scopes on a +/// user-created token are whatever the caller typed, so if the scope granted anything +/// then any member of any workspace could mint themselves non-member access to every +/// guest-mode app on the instance. +#[sqlx::test(fixtures("base"))] +async fn a_self_declared_guest_scope_grants_nothing(db: Pool) -> 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"); + + // `users/tokens/create` must refuse the label outright... + let resp = authed( + client().post(format!("http://localhost:{port}/api/users/tokens/create")), + ADMIN_TOKEN, + ) + .json(&json!({ "label": "guest_session", "scopes": guest_scopes() })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "the guest session label must be server-minted only" + ); + + // ...and so must relabelling an ordinary token into it, or the pin-less user + // token would become a guest session that authenticates in every workspace. + let resp = authed( + client().post(format!("http://localhost:{port}/api/users/tokens/create")), + ADMIN_TOKEN, + ) + .json(&json!({ "label": "mine", "scopes": guest_scopes() })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let prefix: String = sqlx::query_scalar( + "SELECT token_prefix FROM token WHERE email = 'test@windmill.dev' AND label = 'mine'", + ) + .fetch_one(&db) + .await?; + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/users/tokens/update_label/{prefix}" + )), + ADMIN_TOKEN, + ) + .json(&json!({ "label": "guest_session" })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "relabelling into the guest namespace must be refused: {}", + resp.text().await? + ); + + // ...and a token that carries the scopes under any other label authenticates as + // nothing in a workspace its owner is not a member of. + // An email with no `usr` row anywhere: exactly the identity the guest arm exists + // to admit, and the one a forged scope must not admit. + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, scopes) + VALUES (encode(sha256($1::bytea), 'hex'), 'FORGED_SCO', $2, 'outsider@example.com', + 'forged', $3)", + ) + .bind(b"FORGED_SCOPES".as_slice()) + .bind("FORGED_SCOPES") + .bind(guest_scopes()) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{ws}/users/whoami")), "FORGED_SCOPES") + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "declaring the guest scope must not turn a non-member into an identity" + ); + + Ok(()) +} + +/// A guest-mode policy that names one runnable, so an `execute_component` request +/// gets past the triggerables lookup and reaches the guest gate. `sandbox` is what +/// makes the embed-token endpoint actually mint a token. +fn guest_app_with_runnable(path: &str, sandbox: bool) -> serde_json::Value { + app_with_runnable(path, "guest", sandbox) +} + +fn app_with_runnable(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": {} } + } + } + }) +} + +fn execute(port: u16, ws: &str, app: &str, token: &str) -> reqwest::RequestBuilder { + authed( + client().post(format!( + "http://localhost:{port}/api/w/{ws}/apps_u/execute_component/{app}" + )), + token, + ) + .json(&json!({ + "component": "a", + "path": "script/u/test-user/noop", + "args": {} + })) +} + +/// The workspace switch is enforced at the auth door for every guest request, not +/// remembered per handler. This is what stands between a `guest` policy pushed by +/// git-sync and execution once an admin has turned guests off — and it closes the +/// app to sessions already issued. +#[sqlx::test(fixtures("base"))] +async fn the_door_re_checks_the_workspace_switch(db: Pool) -> 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"); + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH, false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; + + // Switch off: the session does not authenticate at all, even though the app's + // policy says guest and the session was (in this fixture) issued regardless. On + // the authed route that is a 401; on the optional-auth run route the rejected + // token reads as no token, and a guest-mode app then refuses the anonymous + // caller — a denial either way. + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "a guest must not authenticate while guests are off" + ); + let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) + .send() + .await?; + assert!( + resp.status().is_client_error() && resp.status() != 404, + "a guest must not run while guests are off, got {}", + resp.status() + ); + + // Switch on: through the door. What follows the run is the runnable lookup, + // which fails on the nonexistent script — the point is that it is no longer a + // denial. + enable_guests(port, "test-workspace").await?; + let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) + .send() + .await?; + assert!( + resp.status() != 401 && resp.status() != 403, + "with guests on, the door must let the run through: {}", + resp.status() + ); + + Ok(()) +} + +/// The path scope is what keeps a guest to the one app it was let in for: the route +/// layer is resource-blind for `apps:run`, so this line is drawn in the handler. +#[sqlx::test(fixtures("base"))] +async fn guest_cannot_run_another_guest_app(db: Pool) -> 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 other = "u/test-user/other_guest_app"; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(other, false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; // scoped to APP_PATH, not `other` + + let resp = execute(port, "test-workspace", other, GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a guest session scoped to one app must not run another, even one open to guests" + ); + + Ok(()) +} + +/// The app path is spliced into the session's scopes, whose grammar reserves `:`, `,` +/// and `*`: a path carrying one would scope the guest to more than the one app it was +/// let in for, so the mint refuses it before anything else. Anything else in a path +/// (spaces, `@`) is literal to that grammar and stays admissible. +#[sqlx::test(fixtures("base"))] +async fn a_scope_metacharacter_in_the_app_path_is_refused( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let mint = |path: &'static str| { + let db = db.clone(); + async move { + let mut tx = db.begin().await?; + let minted = windmill_api_users::users::create_guest_session_token( + "guest@example.com", + "test-workspace", + path, + &mut tx, + tower_cookies::Cookies::default(), + ) + .await; + anyhow::Ok(minted) + } + }; + for path in [ + "u/test-user/entry,u/test-user/hidden", + "u/test-user/*", + "u/test-user/entry:run", + ] { + let minted = mint(path).await?; + assert!( + matches!(minted, Err(windmill_common::error::Error::BadRequest(ref m)) if m.contains("cannot be scoped")), + "{path}: {minted:?}" + ); + } + for path in ["u/test-user/My App", "u/admin@windmill.dev/x"] { + let minted = mint(path).await?; + assert!( + !matches!(minted, Err(windmill_common::error::Error::BadRequest(ref m)) if m.contains("cannot be scoped")), + "{path} is literal to the scope grammar and must get past the guard: {minted:?}" + ); + } + Ok(()) +} + +/// A guest reads the jobs it launched and nothing else: with no membership behind it, +/// it must stop where an app embed token stops, before the share-token and ACL grants +/// a member would get, and with the same "not found" so it cannot probe for jobs. +#[sqlx::test(fixtures("base"))] +async fn a_guest_cannot_read_a_job_it_did_not_launch(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + insert_guest_token(&db, "test-workspace").await?; + let resp = authed(client().post(format!("{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?); + let resp = authed( + client().post(format!("{ws}/jobs/run/p/u/test-user/noop")), + ADMIN_TOKEN, + ) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let job_id = resp.text().await?; + + let resp = authed( + client().get(format!("{ws}/jobs_u/getupdate/{job_id}")), + GUEST_TOKEN, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "another caller's job is not found for a guest: {}", + resp.text().await? + ); + + Ok(()) +} + +/// Guests mode cannot land on a path the scope grammar cannot hold, however it gets +/// there: set at creation, set on update, or a rename of an app already in that mode. +#[sqlx::test(fixtures("base"))] +async fn guests_mode_needs_a_scopable_path(db: Pool) -> 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"); + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable("u/test-user/a:b", false)) + .send() + .await?; + assert_eq!(resp.status(), 400, "created into Guests on a `:` path"); + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH, false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let resp = authed( + client().post(format!("{ws}/apps/update/{APP_PATH}")), + ADMIN_TOKEN, + ) + .json(&json!({ "path": "u/test-user/a,b" })) + .send() + .await?; + assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests"); + let resp = authed( + client().post(format!("{ws}/apps/update/{APP_PATH}")), + ADMIN_TOKEN, + ) + .json(&json!({ "path": "u/test-user/My App" })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a space is literal: {}", + resp.text().await? + ); + + // Set on update: an app that already sits on such a path cannot be switched. + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": "u/test-user/x:y", + "summary": "App", + "value": {}, + "policy": { "execution_mode": "publisher", "triggerables_v2": {} } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user/x:y")), + ADMIN_TOKEN, + ) + .json(&json!({ "policy": { "execution_mode": "guest", "triggerables_v2": {} } })) + .send() + .await?; + assert_eq!(resp.status(), 400, "switched to Guests on a `:` path"); + + Ok(()) +} + +/// Renaming a workspace copies its settings; the guest switch and the guest JWT key must +/// travel with them, or the rename silently shuts every guest app or drops the key. +#[sqlx::test(fixtures("base"))] +async fn a_workspace_rename_keeps_the_guest_switch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + enable_guests(port, "test-workspace").await?; + sqlx::query( + "INSERT INTO guest_activity (email, workspace_id, day) + VALUES ('guest@example.com', 'test-workspace', CURRENT_DATE)", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE workspace_settings SET guest_jwt_public_key = 'test-pem-key' WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id" + )), + ADMIN_TOKEN, + ) + .json(&json!({ "new_id": "test-workspace-2", "new_name": "Test workspace 2" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let enabled: bool = sqlx::query_scalar( + "SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = 'test-workspace-2'", + ) + .fetch_one(&db) + .await?; + assert!(enabled, "the guest switch travels with the workspace"); + let jwt_key: Option = sqlx::query_scalar( + "SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'", + ) + .fetch_one(&db) + .await?; + assert_eq!( + jwt_key.as_deref(), + Some("test-pem-key"), + "the guest JWT key travels with the workspace" + ); + let moved: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace-2') + AND NOT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace')", + ) + .fetch_one(&db) + .await?; + assert!(moved, "the guests seen in the workspace follow its new id"); + + Ok(()) +} + +/// The superadmin switch sits above every workspace's: off, no guest session stands and +/// no app discovers as open, whatever the workspace and the app say. +#[sqlx::test(fixtures("base"))] +async fn the_instance_switch_closes_every_workspace(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH, false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; + let set_instance_switch = |disabled: bool| { + authed( + client().post(format!( + "http://localhost:{port}/api/settings/global/guest_access_disabled" + )), + ADMIN_TOKEN, + ) + .json(&json!({ "value": disabled })) + .send() + }; + + let secret: String = authed( + client().get(format!("{ws}/apps/secret_of/{APP_PATH}")), + ADMIN_TOKEN, + ) + .send() + .await? + .text() + .await?; + + set_instance_switch(true).await?.error_for_status()?; + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "the instance switch closes an issued session" + ); + let resp = client() + .get(format!("{ws}/apps_u/guest_entry/{secret}")) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "and nothing discovers as open to guests" + ); + + set_instance_switch(false).await?.error_for_status()?; + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!(resp.status(), 200, "back on, the session stands again"); + + Ok(()) +} + +/// An account holder is never a guest, and that holds after the mint too: a session +/// minted before the account existed ends at the door the moment one does, so an +/// account provisioned in a race with the mint cannot outlive the rule. +#[sqlx::test(fixtures("base"))] +async fn an_account_ends_the_guest_session(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + insert_guest_token(&db, "test-workspace").await?; + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!(resp.status(), 200, "guest whoami must resolve"); + + sqlx::query( + "INSERT INTO password (email, password_hash, login_type, super_admin, verified, name) + VALUES ('guest@example.com', 'not-a-real-hash', 'password', false, true, 'Guest')", + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "an account created after the mint ends the guest session at the door" + ); + + Ok(()) +} + +/// An upload goes through an app's `s3_inputs` policy or not at all for a guest: the +/// legacy branch for an app without one uploads with the caller's own standing, which a +/// guest has none of, and an app path with no row must not slip past the confinement. +#[cfg(feature = "parquet")] +#[sqlx::test(fixtures("base"))] +async fn a_guest_cannot_upload_outside_a_policy(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH, false)) // no `s3_inputs` + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; + + let upload = |app: &str| { + authed( + client().post(format!( + "{ws}/apps_u/upload_s3_file/{app}?file_key=anything" + )), + GUEST_TOKEN, + ) + .body("x") + .send() + }; + let resp = upload("u/test-user/no_such_app").await?; + assert_eq!( + resp.status(), + 403, + "a path with no app must not escape the guest's confinement: {}", + resp.text().await? + ); + let resp = upload(APP_PATH).await?; + assert_eq!( + resp.status(), + 400, + "without an upload policy a guest is refused like an anonymous caller: {}", + resp.text().await? + ); + + Ok(()) +} + +/// An anonymous app is open to anyone, a guest included, and the guest uses it as +/// itself: the component run and the result read that follows are one identity, so +/// the read's launched-by-me grant matches. Acting as nobody for the run and as the +/// guest for the read would start a job whose result the page can never fetch. +#[sqlx::test(fixtures("base"))] +async fn a_guest_uses_an_anonymous_app_as_itself(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + let resp = authed(client().post(format!("{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?); + let anon = "u/test-user/anon_app"; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&app_with_runnable(anon, "anonymous", false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; // scoped to APP_PATH, not `anon` + + let resp = execute(port, "test-workspace", anon, GUEST_TOKEN) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let job_id = resp.text().await?; + + let resp = authed( + client().get(format!("{ws}/jobs_u/getupdate/{job_id}")), + GUEST_TOKEN, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "the guest that started the run must be able to read it back: {}", + resp.text().await? + ); + + Ok(()) +} + +/// The embed token a guest mints for a sandboxed app is the one credential handed to +/// untrusted app JS. It must be a guest twice over — resolve like its minter (the +/// label) and be governed like its minter (the sentinel) — or every guest control +/// silently skips the most exposed credential there is. +#[sqlx::test(fixtures("base"))] +async fn a_guest_minted_embed_token_stays_a_guest(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH, true)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let secret: String = authed( + client().get(format!("{ws}/apps/secret_of/{APP_PATH}")), + ADMIN_TOKEN, + ) + .send() + .await? + .text() + .await?; + insert_guest_token(&db, "test-workspace").await?; + + // The guest page mints the iframe's token from the guest session. + let resp = authed( + client().get(format!("{ws}/apps_u/embed_token/{secret}")), + GUEST_TOKEN, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a guest must be able to mint: {}", + resp.text().await? + ); + let body: serde_json::Value = resp.json().await?; + let embed = body["token"] + .as_str() + .expect("mint must return a token for an authenticated guest") + .to_string(); + + // Its lifetime is capped at the session that minted it: the requested embed + // validity (12h) is longer than the guest session's (8h in this fixture), and the + // session's expiry is a guest's only revocation. + let parent_exp: chrono::DateTime = + sqlx::query_scalar("SELECT expiration FROM token WHERE token_prefix = 'GUEST_SECR'") + .fetch_one(&db) + .await?; + let child_exp: chrono::DateTime = body["expiration"] + .as_str() + .and_then(|e| e.parse().ok()) + .expect("mint must return the token's expiration"); + assert!( + child_exp <= parent_exp, + "a guest's embed token must not outlive the session that minted it ({child_exp} > {parent_exp})" + ); + + // Resolves — and as a guest, not as the non-member superadmin shape. + let resp = authed(client().get(format!("{ws}/users/whoami")), &embed) + .send() + .await?; + assert_eq!(resp.status(), 200, "the minted token must authenticate"); + let me: serde_json::Value = resp.json().await?; + assert_eq!(me["role"], json!("guest")); + + // Governed: the workspace switch closes it at the door, iframe or not. + authed( + client().post(format!("{ws}/workspaces/edit_guest_access")), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": false })) + .send() + .await?; + let resp = authed(client().get(format!("{ws}/users/whoami")), &embed) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "turning guests off must stop a guest's embed token authenticating" + ); + let resp = execute(port, "test-workspace", APP_PATH, &embed) + .send() + .await?; + assert!( + resp.status().is_client_error() && resp.status() != 404, + "and running components, got {}", + resp.status() + ); + enable_guests(port, "test-workspace").await?; + + // And its scopes are not something the guest's email can later rewrite. The + // guest session itself cannot reach `/users/*` (workspace pin), so model the real + // threat: the same email after promotion, holding an ordinary unpinned session. + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label) + VALUES (encode(sha256($1::bytea), 'hex'), 'PROMOTED_S', $2, 'guest@example.com', + 'session')", + ) + .bind(b"PROMOTED_SESSION".as_slice()) + .bind("PROMOTED_SESSION") + .execute(&db) + .await?; + for prefix in [&embed[..10], &GUEST_TOKEN[..10]] { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/users/tokens/update_scopes/{prefix}" + )), + "PROMOTED_SESSION", + ) + .json(&json!({ "scopes": null })) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "a promoted account must not be able to rescope its old guest credentials" + ); + } + + Ok(()) +} + +/// The label is the single source of truth: a guest-labelled credential is governed +/// as a guest even if its scopes carry no sentinel. Otherwise every mint that derives +/// a token from a guest session is one forgotten `push` away from an ungoverned +/// non-member credential. +#[sqlx::test(fixtures("base"))] +async fn a_guest_label_is_governed_without_the_sentinel(db: Pool) -> 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"); + + enable_guests(port, "test-workspace").await?; + let scopes: Vec = guest_scopes() + .into_iter() + .filter(|s| s != "guest") + .collect(); + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, scopes, workspace_id, expiration) + VALUES (encode(sha256($1::bytea), 'hex'), 'NOSENTINE_', $2, 'guest@example.com', + 'guest_session', $3, 'test-workspace', now() + interval '8 hours')", + ) + .bind(b"NOSENTINEL".as_slice()) + .bind("NOSENTINEL") + .bind(scopes) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{ws}/users/whoami")), "NOSENTINEL") + .send() + .await?; + assert_eq!(resp.status(), 200); + let me: serde_json::Value = resp.json().await?; + assert_eq!( + me["role"], + json!("guest"), + "the label alone must make a credential a guest" + ); + let resp = authed(client().get(format!("{ws}/jobs/list")), "NOSENTINEL") + .send() + .await?; + assert_eq!(resp.status(), 403, "and confine it like one"); + + Ok(()) +} + +/// A guest is someone with no account at all — including a deactivated one. The +/// sign-in path's own account lookup filters on `disabled = false`, so a disabled +/// account reads as absent there; the mint has to refuse on its own or deactivation +/// (manual or SCIM, whose revocation is "delete the tokens") walks straight back in. +#[sqlx::test(fixtures("base"))] +async fn a_disabled_account_cannot_become_a_guest(db: Pool) -> 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(&guest_app_with_runnable(APP_PATH, false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + sqlx::query( + "INSERT INTO password (email, password_hash, login_type, super_admin, verified, disabled) + VALUES ('gone@example.com', 'x', 'password', false, true, true)", + ) + .execute(&db) + .await?; + + let mut tx = db.begin().await?; + let cookies = tower_cookies::Cookies::default(); + let minted = windmill_api_users::users::create_guest_session_token( + "gone@example.com", + "test-workspace", + APP_PATH, + &mut tx, + cookies, + ) + .await; + assert!( + matches!(minted, Err(windmill_common::error::Error::NotAuthorized(_))), + "a deactivated account must be refused a guest session, got {minted:?}" + ); + + Ok(()) +} diff --git a/backend/tests/app_guest_jwt_allowance.rs b/backend/tests/app_guest_jwt_allowance.rs new file mode 100644 index 0000000000..85cb51d39e --- /dev/null +++ b/backend/tests/app_guest_jwt_allowance.rs @@ -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) -> 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(()) +} diff --git a/backend/tests/app_guest_jwt_entry.rs b/backend/tests/app_guest_jwt_entry.rs new file mode 100644 index 0000000000..7b84130a04 --- /dev/null +++ b/backend/tests/app_guest_jwt_entry.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + iat: Option, +} + +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_`). `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) -> 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) -> 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/` or `g/` 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) -> 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) -> 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) -> 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 = 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, +) -> 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(()) +} diff --git a/backend/tests/postgres_trigger_scope.rs b/backend/tests/postgres_trigger_scope.rs index e8ba36ebb9..1080138eb0 100644 --- a/backend/tests/postgres_trigger_scope.rs +++ b/backend/tests/postgres_trigger_scope.rs @@ -25,6 +25,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed { token_prefix: None, read_only: false, job_id: None, + credential_expiry: None, } } diff --git a/backend/tests/relock_noop.rs b/backend/tests/relock_noop.rs index 5cb8e3f51e..a480fc57ae 100644 --- a/backend/tests/relock_noop.rs +++ b/backend/tests/relock_noop.rs @@ -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) -> 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) -> 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, completed: &mut (impl futures::Stream + 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) -> 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) -> anyhow let mut completed = listen_for_completed_jobs(&db).await; let py = |path: &str, content: &str, parent_hash: Option, 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) -> 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) -> 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) -> 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(); diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index 1463138167..0af99136e7 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -178,6 +178,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed { token_prefix: None, read_only: false, job_id: None, + credential_expiry: None, } } diff --git a/backend/tests/wac_suspend_started_at.rs b/backend/tests/wac_suspend_started_at.rs new file mode 100644 index 0000000000..5a83730d24 --- /dev/null +++ b/backend/tests/wac_suspend_started_at.rs @@ -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) -> 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>, + bool, + i32, + Option>, + ) = 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) -> 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>, + Option>, + ) = 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(()) +} diff --git a/backend/tests/wm_token_confinement.rs b/backend/tests/wm_token_confinement.rs index 2d3f1373fe..39b86c59a1 100644 --- a/backend/tests/wm_token_confinement.rs +++ b/backend/tests/wm_token_confinement.rs @@ -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, } } diff --git a/backend/windmill-api-assets/tests/dbt_pinned_graph.rs b/backend/windmill-api-assets/tests/dbt_pinned_graph.rs index 7f4979a96f..f2bb98a46c 100644 --- a/backend/windmill-api-assets/tests/dbt_pinned_graph.rs +++ b/backend/windmill-api-assets/tests/dbt_pinned_graph.rs @@ -33,6 +33,7 @@ fn outsider() -> ApiAuthed { token_prefix: None, read_only: false, job_id: None, + credential_expiry: None, } } diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index bef59aea6c..6e5aa6b1da 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -138,12 +138,28 @@ impl AuthCache { w_id: Option, token: &str, ) -> Option { - 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_` 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 = Cache::new(2000); + static ref GUEST_JWT_REFUSED_CACHE: Cache = 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(parts: &mut Parts, state: &S) -> Option { 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) -> (Option ( - 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), } } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index f2392839ba..b9edde3c6d 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -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, + /// 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>, } impl ApiAuthed { @@ -165,6 +170,7 @@ impl From 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( diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 05de1183cd..47ea12c04b 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -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) -> Vec { + 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> { + // 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 diff --git a/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs b/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs index 3e1035ff82..25942e845c 100644 --- a/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs +++ b/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs @@ -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, `` 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"; + /// 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, diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index fe80e5fdf4..06c47cbb14 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -63,6 +63,7 @@ fn test_authed() -> ApiAuthed { token_prefix: None, read_only: false, job_id: None, + credential_expiry: None, } } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 47b520736a..afe7580055 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -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>( "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, authed: ApiAuthed, ) -> JsonResult { + 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::().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:` 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 { + 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 = 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-%' diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index bb4310d1bc..ef5ea37e1d 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -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, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index cb5c639d46..c9343840d2 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub guest_jwt_jwks_url: Option, } /// Subset of `WorkspaceSettings` that is safe to return to any workspace @@ -339,6 +353,9 @@ pub struct WorkspacePublicSettings { pub teams_team_guid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mute_critical_alerts: Option, + /// 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(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, + Path(_w_id): Path, +) -> JsonResult { + 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, + Path(w_id): Path, + Json(EditGuestAccess { guest_access_enabled }): Json, +) -> Result { + 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, + jwks_url: Option, +} + +/// 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, + Path(w_id): Path, + Json(EditGuestJwtKey { public_key, jwks_url }): Json, +) -> Result { + 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, @@ -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, }) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 1968437e40..b2883e5f9a 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e280586a1a..56918747d4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 @@ -8883,6 +9015,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 @@ -13043,6 +13196,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 @@ -28804,6 +28981,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: @@ -33214,14 +33461,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: @@ -33272,7 +33522,7 @@ components: format: date-time execution_mode: type: string - enum: [viewer, publisher, anonymous] + enum: [viewer, publisher, guest, anonymous] raw_app: type: boolean labels: @@ -35086,6 +35336,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: @@ -35094,6 +35355,7 @@ components: - RestrictDeployToDeployers - RestrictAnonymousAppDeployment - RestrictPublicRunSharing + - RestrictGuestAppDeployment RuleBypasserGroups: type: array description: Groups that can bypass this ruleset diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index dbc8bfb0a7..36b6eab0bb 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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; #[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 { + 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, +) -> Result { + 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, + mode: ExecutionMode, + app_path: &str, +) -> Result> { + 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, +) -> 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::(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, +) -> Result)>> { + 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>> = 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::>() + } + _ => 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 = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect(); + let mut scopes: Vec = 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, + Path((w_id, secret)): Path<(String, String)>, +) -> JsonResult { + 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 { 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>( + "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::(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, ) -> 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:`) // 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 { // 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::(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, 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))) } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 53044ee020..dbf33cdddc 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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, } } diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 054af0f2fa..4ba60428c9 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -378,6 +378,7 @@ async fn inject_agent_authed( token_prefix: None, read_only: false, job_id: None, + credential_expiry: None, }, job_id: None, }); diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index 65c8069b85..ce5cb478cf 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -1421,6 +1421,7 @@ mod tests { token_prefix: None, read_only: false, job_id, + credential_expiry: None, } } diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 8804004f2b..ed66eb5e74 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -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, + 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, +} + +#[derive(serde::Deserialize)] +struct ListGuestsQuery { + page: Option, + per_page: Option, +} + +/// 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, + Query(query): Query, +) -> Result> { + 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, Extension(argon2): Extension>>, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index e2c3233183..dcf3465a94 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -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 diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index fa1bc96334..b51186f464 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -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. diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index d38396562b..8180e820f2 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -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"; diff --git a/backend/windmill-common/src/guest_jwt.rs b/backend/windmill-common/src/guest_jwt.rs new file mode 100644 index 0000000000..fe05bec6b6 --- /dev/null +++ b/backend/windmill-common/src/guest_jwt.rs @@ -0,0 +1,1008 @@ +//! The guest JWT contract: what a token minted by an embedding customer's own backend +//! must carry to open one guest-mode app, and how it is verified against the workspace's +//! configured key (or, off cloud, the instance issuer). Deliberately narrower than the external JWT scheme +//! (`jwt_ext_`), whose claims can assert admin, groups and folders: a guest key can +//! only ever mint guests, whatever the token says. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use jsonwebtoken::{ + jwk::{AlgorithmParameters, Jwk, PublicKeyUse}, + Algorithm, DecodingKey, Validation, +}; +use quick_cache::sync::Cache; +use serde::Deserialize; + +use crate::error::{Error, Result}; +use crate::DB; + +/// A token is honoured at most this long past its issue, however far its `exp` lies: +/// a guest's expiry is its only revocation, and a long-lived token minted by mistake +/// would otherwise stay valid until it leaked. +pub const MAX_LIFETIME_SECS: u64 = 24 * 60 * 60; + +/// Bearer prefix. Stateless: no `token` row. Verified against the workspace's key (or, off +/// cloud, the instance issuer when the workspace set none) and resolved in the auth cache, +/// whose entry is short-lived (not the token's full `exp`) so a rotated key revokes within +/// minutes. See the arm in `windmill-api-auth`. +pub const BEARER_PREFIX: &str = "jwt_guest_"; + +const RSA_ALGORITHMS: [Algorithm; 6] = [ + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, +]; +const EC_ALGORITHMS: [Algorithm; 2] = [Algorithm::ES256, Algorithm::ES384]; + +/// Every claim honoured. Extra claims are ignored; a missing one refuses the token. +/// `app_path` is mandatory: a token opens that one app, exactly as a signed-in guest +/// session does. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct GuestJwtClaims { + pub email: String, + pub workspace_id: String, + pub app_path: String, + pub exp: u64, + pub nbf: Option, + pub iat: Option, +} + +/// How a guest JWT is verified: the workspace's configured key (a PEM public key or a JWKS +/// URL), or, off cloud, the instance issuer (`JWT_EXT_JWKS_URL`) when the workspace set none — +/// see `key_source`. When neither is set the JWT is refused whatever it carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuestJwtKeySource { + Pem(String), + JwksUrl(String), +} + +/// The instance-wide external JWT issuer (`JWT_EXT_JWKS_URL`, also used by `jwt_ext_`). Read +/// fresh rather than cached so it is testable and picks up config regardless of init order. +fn instance_ext_jwks_url() -> Option { + std::env::var("JWT_EXT_JWKS_URL") + .ok() + .filter(|s| !s.trim().is_empty()) +} + +pub async fn key_source(db: &DB, w_id: &str) -> Result> { + let row = sqlx::query!( + "SELECT guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await + .map_err(|e| Error::internal_err(format!("reading guest JWT key of {w_id}: {e:#}")))?; + let per_workspace = row.and_then(|r| match (r.guest_jwt_public_key, r.guest_jwt_jwks_url) { + (Some(pem), _) => Some(GuestJwtKeySource::Pem(pem)), + (None, Some(url)) => Some(GuestJwtKeySource::JwksUrl(url)), + (None, None) => None, + }); + if per_workspace.is_some() { + return Ok(per_workspace); + } + // No workspace key: fall back to the instance issuer, so an operator running one issuer for + // both `jwt_ext_` and guests configures it once. Verifying it (and granting a *guest*) is + // done here in CE; granting a full login from it stays EE (`jwt_ext_`). Not on the shared + // cloud, where one instance issuer must not be trusted to mint guests in every tenant's + // workspace — there the per-workspace key is the only source. + if !*crate::worker::CLOUD_HOSTED { + if let Some(url) = instance_ext_jwks_url() { + return Ok(Some(GuestJwtKeySource::JwksUrl(url))); + } + } + Ok(None) +} + +/// Parse a PEM public key and the algorithms it may verify: RSA keys the RS/PS family, +/// EC keys the ES family. Anything symmetric has no PEM form, so HS* is unreachable +/// from here by construction; the JWKS path refuses it explicitly. +pub fn decoding_key_from_pem(pem: &str) -> Result<(DecodingKey, &'static [Algorithm])> { + use base64::{engine::general_purpose::STANDARD, Engine}; + use spki::der::Decode; + // The key is admin-set into an unbounded `TEXT` column and reparsed on every guest-JWT + // request; a well-formed key with an oversized modulus would pass the checks below. Refuse + // one larger than any real public key before decoding or storing it. Measure the untrimmed + // input: the endpoint stores what the admin sent, so whitespace padding counts too. + if pem.len() > MAX_GUEST_PEM_LEN { + return Err(Error::BadRequest(format!( + "guest key is longer than {MAX_GUEST_PEM_LEN} bytes" + ))); + } + let pem = pem.trim(); + // A verification key must be public. jsonwebtoken 8.3 keys the public/private distinction + // off the PEM label alone and never inspects the DER, so private material relabelled + // `PUBLIC KEY` would be stored and then served back through the settings response. Decode + // the body leniently, as jsonwebtoken does (tolerating any wrapping the strict RFC 7468 + // decoder would refuse), then require it to be a public-key structure: an SPKI (RSA or EC) + // or a PKCS#1 RSA public key. Private-key DER satisfies neither. + let der = STANDARD + .decode( + pem.lines() + .filter(|l| !l.trim_start().starts_with("-----")) + .flat_map(|l| l.split_whitespace()) + .collect::(), + ) + .map_err(|e| Error::BadRequest(format!("guest key is not valid PEM: {e}")))?; + let is_public = spki::SubjectPublicKeyInfoRef::from_der(&der).is_ok() + || pkcs1::RsaPublicKey::from_der(&der).is_ok(); + if !is_public { + return Err(Error::BadRequest( + "expected an RSA or EC public key in PEM form (-----BEGIN PUBLIC KEY-----)".to_string(), + )); + } + if let Ok(key) = DecodingKey::from_rsa_pem(pem.as_bytes()) { + return Ok((key, &RSA_ALGORITHMS)); + } + if let Ok(key) = DecodingKey::from_ec_pem(pem.as_bytes()) { + return Ok((key, &EC_ALGORITHMS)); + } + Err(Error::BadRequest( + "not an RSA or EC public key in PEM form (expected -----BEGIN PUBLIC KEY-----)".to_string(), + )) +} + +/// The algorithms a JWKS key may verify, or `None` if the key is unusable here: a +/// symmetric key (HS*, a shared secret the embedder would then have to hold), an +/// unsupported family, or a key not marked for signatures. A key that names its `alg` +/// pins that one; an RSA key that omits it accepts the whole RSA family, and an EC key +/// the algorithm its curve implies, mirroring how a PEM key is accepted. +pub fn jwk_algorithms(jwk: &Jwk) -> Option> { + if jwk.common.public_key_use.is_some() + && jwk.common.public_key_use != Some(PublicKeyUse::Signature) + { + return None; + } + // A key that lists its operations must allow verifying signatures; otherwise it is + // published for something else (encryption, key wrapping) and is not ours to use. + if jwk + .common + .key_operations + .as_ref() + .is_some_and(|ops| !ops.contains(&jsonwebtoken::jwk::KeyOperations::Verify)) + { + return None; + } + match (&jwk.algorithm, jwk.common.algorithm) { + (AlgorithmParameters::RSA(_), Some(alg)) if RSA_ALGORITHMS.contains(&alg) => { + Some(vec![alg]) + } + (AlgorithmParameters::RSA(_), None) => Some(RSA_ALGORITHMS.to_vec()), + (AlgorithmParameters::EllipticCurve(_), Some(alg)) if EC_ALGORITHMS.contains(&alg) => { + Some(vec![alg]) + } + (AlgorithmParameters::EllipticCurve(p), None) => match p.curve { + jsonwebtoken::jwk::EllipticCurve::P256 => Some(vec![Algorithm::ES256]), + jsonwebtoken::jwk::EllipticCurve::P384 => Some(vec![Algorithm::ES384]), + _ => None, + }, + _ => None, + } +} + +/// Verify `token` against `key`, honouring only the accepted `algorithms`, and check +/// every claim rule that needs no database: signature, `exp` (mandatory), `nbf` and +/// `iat` when present, the lifetime cap, that the token names `w_id`, that `email` is a +/// valid address bounded to 254 bytes, and that `app_path` carries no scope metacharacter. +pub fn verify( + token: &str, + key: &DecodingKey, + algorithms: &[Algorithm], + w_id: &str, +) -> Result { + let mut validation = Validation::new(algorithms[0]); + validation.algorithms = algorithms.to_vec(); + validation.validate_nbf = true; + let claims = jsonwebtoken::decode::(token, key, &validation) + .map_err(|e| Error::NotAuthorized(format!("guest JWT refused: {e}")))? + .claims; + let now = jsonwebtoken::get_current_timestamp(); + if claims.exp > now + MAX_LIFETIME_SECS { + return Err(Error::NotAuthorized(format!( + "guest JWT refused: exp is more than {MAX_LIFETIME_SECS} seconds ahead" + ))); + } + if let Some(iat) = claims.iat { + if iat > now + validation.leeway { + return Err(Error::NotAuthorized( + "guest JWT refused: iat is in the future".to_string(), + )); + } + if claims.exp.saturating_sub(iat) > MAX_LIFETIME_SECS { + return Err(Error::NotAuthorized(format!( + "guest JWT refused: lifetime exceeds {MAX_LIFETIME_SECS} seconds" + ))); + } + } + if claims.workspace_id != w_id { + return Err(Error::NotAuthorized( + "guest JWT refused: workspace_id does not match the workspace".to_string(), + )); + } + // The email becomes the guest's username; require the address shape the `usr` table + // accepts (`VALID_EMAIL`), so it always carries an `@` and `username_to_permissioned_as` + // can only ever read it as its own principal, never a `u/` or `g/`. + // Bound it to fit the `guest_activity.email` column: a longer one fails that insert + // while the guest is admitted uncounted. + if !crate::users::VALID_EMAIL.is_match(&claims.email) || claims.email.len() > 254 { + return Err(Error::NotAuthorized( + "guest JWT refused: email is not a valid, bounded email address".to_string(), + )); + } + // The app path is spliced into `apps:read:` and `apps:run:` scopes, whose + // grammar reserves `:`, `,`, `*` and a leading `/`; refuse those, the same guard + // `guest_session_scopes` applies at the mint. App paths may carry spaces and `@`. + if !crate::auth::is_scope_literal_path(&claims.app_path) { + return Err(Error::NotAuthorized( + "guest JWT refused: app_path is empty or cannot be scoped (`:`, `,`, `*` are \ + reserved and a leading `/` never matches a route)" + .to_string(), + )); + } + Ok(claims) +} + +struct JwksEntry { + keys: Arc>, + /// When this entry stops being served and the next request refetches. A good fetch + /// is served for `JWKS_TTL`, a failed one for `JWKS_NEGATIVE_TTL` (serving the last + /// good keys if there are any), so an unreachable issuer cannot be turned into one + /// outbound fetch per request by unauthenticated traffic. + expires_at: Instant, + /// When these keys were last fetched successfully. Stale keys are served only within + /// `JWKS_MAX_STALE` of this, and a stale re-serve preserves it, so a revoked `kid` or an + /// unreachable issuer stops minting new guest JWTs after a bounded window, not forever. + fetched_at: Instant, +} + +lazy_static::lazy_static! { + static ref JWKS_CACHE: Cache> = Cache::new(200); + /// Per-URL fetch lock: only one refresh per URL is in flight at a time, so a cold + /// or stale entry under a burst triggers one fetch, not one per request. This is a + /// plain map, not a capacity-bounded `Cache`: a `Cache` could evict a lock whose fetch + /// is still running, and the next request for that URL would then mint a fresh lock and + /// start a duplicate fetch, so cycling past 200 cold URLs could defeat single-flight and + /// storm the issuers. `JwksFetchLock` drops each entry once its last holder is gone, so + /// the map only ever holds the fetches in flight (bounded by concurrent distinct URLs). + static ref JWKS_FETCH_LOCKS: std::sync::Mutex>>> = + std::sync::Mutex::new(HashMap::new()); +} + +/// How long a good key set is served before a refresh; also the lag before a +/// rotated-in `kid` is picked up. The cadence of the instance-level external JWKS. +const JWKS_TTL: Duration = Duration::from_secs(15 * 60); +/// How long a failed fetch is remembered before retrying, so an unreachable issuer is +/// hit at most once per this interval however much guest-JWT traffic arrives. +const JWKS_NEGATIVE_TTL: Duration = Duration::from_secs(30); +/// The absolute age past which cached keys are no longer served, even while revalidating: +/// once an issuer has been unreachable (or has revoked a `kid`) for this long, its old keys +/// stop authenticating and the request fails closed rather than trusting them indefinitely. +const JWKS_MAX_STALE: Duration = Duration::from_secs(60 * 60); +/// A JWKS body larger than this is refused rather than buffered: the URL is admin-set +/// but the server it names may be attacker-controlled, and a real key set is a few KB. +const JWKS_MAX_BYTES: usize = 1 << 20; + +/// A cache entry retains only the usable signing keys, but nothing else bounds their combined +/// size: the response cap is 1 MiB and `from_jwk` decodes `n`/`e`/`x`/`y` without a length +/// limit, so one entry could retain ~1 MiB (a few hundred MB across the 200-entry LRU). Cap the +/// retained material instead; a real set is a few KB, so this is invisible to a legitimate one. +const JWKS_MAX_RETAINED_BYTES: usize = 64 * 1024; + +/// The retained-bytes cap counts key material; this caps the number of keys so the per-key +/// fixed cost (each `Jwk` and its map slot) is bounded too, not just their string content. +const JWKS_MAX_KEYS: usize = 50; + +/// Both JWKS caches key on the admin-supplied URL string. The column is unbounded `TEXT`, so +/// without this a workspace admin could grow the caches by the URL bytes alone. Enforced in +/// `fetch_jwks`, which `edit_guest_jwt_key` validates through, so an overlong URL is never +/// stored; the cache only ever sees a URL that was stored, hence a bounded one. +const MAX_JWKS_URL_LEN: usize = 2048; + +/// A guest verification key is admin-set into an unbounded `TEXT` column and reparsed on every +/// guest-JWT request. A real public key PEM is under a few KB (RSA-16384 SPKI is ~2.8 KB), so +/// this bounds the stored and reparsed bytes without refusing any real key. +const MAX_GUEST_PEM_LEN: usize = 8 * 1024; + +/// A guest JWT is refused past this before any signature work or caching: the auth cache keys +/// on the bearer, so an oversized token (unauthenticated at this point) would otherwise be +/// decoded and, if it verified, cached at its full size. A real JWT is well under this. +pub const MAX_GUEST_JWT_LEN: usize = 8 * 1024; + +/// Fetch a JWKS, keeping only the keys usable here. A workspace-admin URL is validated +/// against private ranges and the connect pinned to the validated addresses; redirects are +/// not followed for the same reason. The instance issuer (`JWT_EXT_JWKS_URL`) is exempt from +/// those restrictions — it is operator-configured and trusted (it also backs `jwt_ext_`), so a +/// self-hosted internal (http/private) issuer that works for `jwt_ext_` works for guests too. +/// The body is read with a cap so a hostile endpoint cannot exhaust memory. +pub async fn fetch_jwks(url: &str) -> Result> { + use futures::StreamExt; + if url.len() > MAX_JWKS_URL_LEN { + return Err(Error::BadRequest(format!( + "JWKS URL is longer than {MAX_JWKS_URL_LEN} bytes" + ))); + } + let resp = if instance_ext_jwks_url().as_deref() == Some(url) { + // Operator-trusted issuer: fetch it with the same permissive client `jwt_ext_` uses + // (follows redirects, honors ACCEPT_INVALID_CERTS), so an issuer that works for + // `jwt_ext_` through a redirect or an approved self-signed cert works for guests too. + crate::utils::HTTP_CLIENT_PERMISSIVE.get(url).send().await + } else { + // Workspace-admin URL: validate (https + private ranges), pin the connect to the + // validated addresses, and do not follow redirects — all against SSRF. + let client = crate::ssrf::validate_guest_jwks_url(url) + .await + .map_err(|e| Error::BadRequest(format!("JWKS URL is not allowed: {e}")))? + .apply_dns_pinning(crate::utils::configure_client(reqwest::ClientBuilder::new())) + .user_agent("windmill/beta") + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| Error::internal_err(format!("building JWKS client: {e}")))?; + client.get(url).send().await + } + .and_then(|r| r.error_for_status()) + .map_err(|e| Error::BadRequest(format!("could not fetch JWKS: {e}")))?; + let mut stream = resp.bytes_stream(); + let mut body: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| Error::BadRequest(format!("reading JWKS: {e}")))?; + if body.len() + chunk.len() > JWKS_MAX_BYTES { + return Err(Error::BadRequest(format!( + "JWKS is larger than {JWKS_MAX_BYTES} bytes" + ))); + } + body.extend_from_slice(&chunk); + } + parse_jwks_keys(&body) +} + +/// The usable signing keys in a JWKS body, by `kid`. Each key is parsed on its own and +/// one that does not model as a JWT key is skipped, not fatal: a set may legitimately +/// carry an encryption key (say `alg: "RSA-OAEP"`, which is not in jsonwebtoken's signing +/// `Algorithm` enum and would fail whole-set deserialization) beside its signing keys. +/// +/// A key is kept only if its material actually decodes (`DecodingKey::from_jwk`): jsonwebtoken +/// carries `n`/`e`/`x`/`y` as strings and defers decoding to auth time, so without this a JWKS +/// whose only key is malformed would be accepted at save time and fail every token later. +fn parse_jwks_keys(body: &[u8]) -> Result> { + let set: serde_json::Value = serde_json::from_slice(body) + .map_err(|e| Error::BadRequest(format!("JWKS is not JSON: {e}")))?; + let entries = set + .get("keys") + .and_then(|k| k.as_array()) + .ok_or_else(|| Error::BadRequest("JWKS has no `keys` array".to_string()))?; + let keys: HashMap = entries + .iter() + .filter_map(|entry| serde_json::from_value::(entry.clone()).ok()) + .filter(|jwk| jwk_algorithms(jwk).is_some()) + .filter(|jwk| DecodingKey::from_jwk(jwk).is_ok()) + .filter_map(|jwk| jwk.common.key_id.clone().map(|kid| (kid, jwk))) + .collect(); + if keys.is_empty() { + return Err(Error::BadRequest( + "JWKS holds no RSA or EC signing key with a kid".to_string(), + )); + } + // Bound the usable keys two ways, both measured after filtering so a large mixed-use set + // (many encryption keys, few signing) is not refused for its size: their count (the per-key + // fixed cost) and their combined material bytes. + if keys.len() > JWKS_MAX_KEYS { + return Err(Error::BadRequest(format!( + "JWKS holds more than {JWKS_MAX_KEYS} usable signing keys" + ))); + } + let retained: usize = keys + .values() + .filter_map(|jwk| serde_json::to_vec(jwk).ok().map(|v| v.len())) + .sum(); + if retained > JWKS_MAX_RETAINED_BYTES { + return Err(Error::BadRequest(format!( + "JWKS signing keys retain more than {JWKS_MAX_RETAINED_BYTES} bytes" + ))); + } + Ok(keys) +} + +/// A cached entry that holds no keys is a remembered failure; serving it would report +/// an unreachable issuer as an unknown `kid`. Map it to an issuer-unreachable error. +fn servable(entry: Arc) -> Result> { + if entry.keys.is_empty() { + Err(Error::NotAuthorized( + "guest JWT refused: the JWKS issuer is unreachable".to_string(), + )) + } else { + Ok(entry) + } +} + +/// A held single-flight lock for one JWKS URL. Dropping it removes the URL from +/// `JWKS_FETCH_LOCKS` once no other holder remains, so the registry never keeps a lock past +/// its fetch and stays bounded by the number of fetches in flight, not by URLs ever seen. +struct JwksFetchLock { + url: String, + lock: Arc>, +} + +impl JwksFetchLock { + /// The lock for `url`, created on first use. Callers sharing a URL get the same `Arc`, + /// so one holds the inner mutex and fetches while the rest wait on it. + fn acquire(url: &str) -> Self { + let mut map = JWKS_FETCH_LOCKS.lock().unwrap(); + let lock = map + .entry(url.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + JwksFetchLock { url: url.to_string(), lock } + } +} + +impl Drop for JwksFetchLock { + fn drop(&mut self) { + let mut map = JWKS_FETCH_LOCKS.lock().unwrap(); + // Clones are only taken while holding this same map lock, so the count is stable + // here: two Arcs (the map's and this one's) means we are the last holder and the + // entry can go; more means another request still needs it and will remove it in turn. + if map + .get(&self.url) + .is_some_and(|lock| Arc::strong_count(lock) <= 2) + { + map.remove(&self.url); + } + } +} + +/// Refresh a URL's JWKS off the request path, under the single-flight lock. A held +/// lock means a refresh is already running, so this is a no-op. A failed refresh +/// leaves the served stale keys in place rather than dropping them. +fn spawn_jwks_refresh(url: String) { + tokio::spawn(async move { + let fetch_lock = JwksFetchLock::acquire(&url); + let Ok(_guard) = fetch_lock.lock.try_lock() else { + return; + }; + match fetch_jwks(&url).await { + Ok(keys) => { + let now = Instant::now(); + JWKS_CACHE.insert( + url, + Arc::new(JwksEntry { + keys: Arc::new(keys), + expires_at: now + JWKS_TTL, + fetched_at: now, + }), + ); + } + Err(e) => tracing::warn!("guest JWKS background refresh failed for {url}: {e:#}"), + } + }); +} + +/// The workspace's JWKS. A fresh entry is served directly; a stale-but-good one is +/// served while a refresh runs off the request path (`spawn_jwks_refresh`), so a slow +/// issuer never stalls a request. Only a cold or negative entry blocks, under a per-URL +/// lock so a burst triggers one fetch; a failed fetch there caches a short-lived empty +/// entry that reads as "issuer unreachable", so an unreachable issuer is hit at most +/// once per `JWKS_NEGATIVE_TTL`. Fetches follow a schedule, never a per-request, +/// attacker-chosen `kid`. +async fn cached_jwks(url: &str) -> Result> { + if let Some(entry) = JWKS_CACHE.get(url) { + // Keys past JWKS_MAX_STALE are never served, even while revalidating: a stale re-serve + // bumps expires_at but keeps fetched_at, so a persistently failing refresh would + // otherwise serve revoked keys forever. Too-old keys fall through to the blocking + // refresh, which fails closed if the issuer is still down. + if entry.fetched_at.elapsed() < JWKS_MAX_STALE { + if entry.expires_at > Instant::now() { + return servable(entry); + } + // Stale but still holds good keys: serve them now and refresh off the request + // path, so a slow or hanging issuer adds no latency. Bump the entry first so the + // refresh window does not spawn a task per request. A negative (empty) entry + // falls through to the blocking refresh below. + if !entry.keys.is_empty() { + let served = Arc::new(JwksEntry { + keys: entry.keys.clone(), + expires_at: Instant::now() + JWKS_NEGATIVE_TTL, + fetched_at: entry.fetched_at, + }); + JWKS_CACHE.insert(url.to_string(), served.clone()); + spawn_jwks_refresh(url.to_string()); + return Ok(served); + } + } + } + // Cold or negative entry, nothing good to serve: block on a single-flight refresh. + // `JwksFetchLock::acquire` hands cold requests the same lock, so they share one fetch. + let fetch_lock = JwksFetchLock::acquire(url); + let _guard = fetch_lock.lock.lock().await; + // Another task may have refreshed while we waited for the lock (honour the age limit too, + // so a concurrent stale re-serve of too-old keys is not mistaken for a fresh entry). + if let Some(entry) = JWKS_CACHE.get(url) { + if entry.fetched_at.elapsed() < JWKS_MAX_STALE && entry.expires_at > Instant::now() { + return servable(entry); + } + } + match fetch_jwks(url).await { + Ok(keys) => { + let now = Instant::now(); + let entry = Arc::new(JwksEntry { + keys: Arc::new(keys), + expires_at: now + JWKS_TTL, + fetched_at: now, + }); + JWKS_CACHE.insert(url.to_string(), entry.clone()); + Ok(entry) + } + Err(e) => { + // Reached with no servable keys: either nothing cached, or keys too old to trust. + // Cache a short negative entry so the next requests do not each refetch, and + // surface the error, so an issuer that revoked a key or went down fails closed. + JWKS_CACHE.insert( + url.to_string(), + Arc::new(JwksEntry { + keys: Arc::new(HashMap::new()), + expires_at: Instant::now() + JWKS_NEGATIVE_TTL, + fetched_at: Instant::now(), + }), + ); + Err(e) + } + } +} + +/// The key a token's header selects from the workspace's JWKS, by `kid`, and the +/// algorithms it may verify. An unknown `kid` is refused against the cached set rather +/// than triggering a fetch, so varying `kid` cannot drive outbound requests; a +/// genuinely rotated-in key is picked up within `JWKS_TTL`. +pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec)> { + let header = jsonwebtoken::decode_header(token) + .map_err(|e| Error::NotAuthorized(format!("guest JWT refused: {e}")))?; + let kid = header.kid.ok_or_else(|| { + Error::NotAuthorized("guest JWT refused: no kid in the header".to_string()) + })?; + let entry = cached_jwks(url).await?; + let jwk = entry.keys.get(&kid).ok_or_else(|| { + Error::NotAuthorized(format!("guest JWT refused: kid {kid} is not in the JWKS")) + })?; + let algs = jwk_algorithms(jwk).ok_or_else(|| { + Error::NotAuthorized(format!("guest JWT refused: kid {kid} is not a signing key")) + })?; + let key = DecodingKey::from_jwk(jwk) + .map_err(|e| Error::internal_err(format!("unusable JWK {kid}: {e}")))?; + Ok((key, algs)) +} + +/// Verify `token` for `w_id` against whatever key the workspace configured. A PEM key +/// ignores `kid`; a JWKS selects by it. +pub async fn verify_for_workspace(db: &DB, w_id: &str, token: &str) -> Result { + if token.len() > MAX_GUEST_JWT_LEN { + return Err(Error::NotAuthorized(format!( + "guest JWT refused: token is longer than {MAX_GUEST_JWT_LEN} bytes" + ))); + } + let Some(source) = key_source(db, w_id).await? else { + return Err(Error::NotAuthorized(format!( + "guest JWT refused: workspace {w_id} has no guest JWT key" + ))); + }; + match source { + GuestJwtKeySource::Pem(pem) => { + let (key, algorithms) = decoding_key_from_pem(&pem)?; + verify(token, &key, algorithms, w_id) + } + GuestJwtKeySource::JwksUrl(url) => { + let (key, algorithms) = jwks_key_for(&url, token).await?; + verify(token, &key, &algorithms, w_id) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serializes the tests that mutate the process-wide `ALLOW_PRIVATE_GUEST_JWKS_URLS`, so a + /// concurrent run cannot clear it out from under another (mirrors `ssrf.rs`'s test lock). + static TEST_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + fn jwk(v: serde_json::Value) -> Jwk { + serde_json::from_value(v).unwrap() + } + + #[test] + fn rsa_key_with_alg_pins_it() { + let k = jwk(serde_json::json!({"kty":"RSA","alg":"RS384","n":"aa","e":"AQAB"})); + assert_eq!(jwk_algorithms(&k), Some(vec![Algorithm::RS384])); + } + + #[test] + fn rsa_key_without_alg_takes_the_whole_family() { + // The bug this pins: an alg-less RSA key must not be forced to RS256, which + // would reject valid RS384/512 or PS* tokens. + let k = jwk(serde_json::json!({"kty":"RSA","n":"aa","e":"AQAB"})); + assert_eq!(jwk_algorithms(&k), Some(RSA_ALGORITHMS.to_vec())); + } + + #[test] + fn ec_key_takes_its_curve_algorithm() { + let k = jwk(serde_json::json!({"kty":"EC","crv":"P-256","x":"aa","y":"bb"})); + assert_eq!(jwk_algorithms(&k), Some(vec![Algorithm::ES256])); + } + + #[test] + fn symmetric_key_is_refused() { + let k = jwk(serde_json::json!({"kty":"oct","k":"c2VjcmV0"})); + assert_eq!(jwk_algorithms(&k), None); + } + + #[test] + fn a_key_marked_for_encryption_is_refused() { + let k = jwk(serde_json::json!({"kty":"RSA","use":"enc","n":"aa","e":"AQAB"})); + assert_eq!(jwk_algorithms(&k), None); + } + + #[test] + fn a_mixed_use_jwks_keeps_only_the_signing_keys() { + // An encryption key (RSA-OAEP is not in jsonwebtoken's signing Algorithm enum) + // beside a signing key must not fail the whole set. The signing key carries real + // coordinates so it survives the material check parse_jwks_keys now applies. + let body = serde_json::json!({ + "keys": [ + {"kty":"RSA","alg":"RSA-OAEP","kid":"enc","use":"enc","n":"aa","e":"AQAB"}, + {"kty":"EC","crv":"P-256","kid":"sig","x":PUB1_X,"y":PUB1_Y} + ] + }) + .to_string(); + let keys = parse_jwks_keys(body.as_bytes()).expect("the signing key survives"); + assert!(keys.contains_key("sig")); + assert!(!keys.contains_key("enc")); + } + + #[test] + fn a_jwks_with_only_malformed_key_material_is_refused() { + // Metadata (kty/alg/use) is fine but `n` is not valid base64url, so the key is + // unusable. Since it is the only key, configuring this JWKS must fail at save time + // rather than persist a URL whose tokens all fail later. + let body = serde_json::json!({ + "keys": [{"kty":"RSA","kid":"k1","n":"not base64url!!","e":"AQAB"}] + }) + .to_string(); + assert!(parse_jwks_keys(body.as_bytes()).is_err()); + } + + #[test] + fn too_many_usable_keys_is_refused() { + // All keys are usable (real coordinates), so this trips the count cap, not the + // empty-set path. Small material, so it is the count that refuses them, not the bytes. + let keys: Vec<_> = (0..=JWKS_MAX_KEYS) + .map(|i| { + serde_json::json!({"kty":"EC","crv":"P-256","kid":format!("k{i}"),"x":PUB1_X,"y":PUB1_Y}) + }) + .collect(); + let body = serde_json::json!({ "keys": keys }).to_string(); + let err = parse_jwks_keys(body.as_bytes()).unwrap_err().to_string(); + assert!(err.contains("usable signing keys"), "{err}"); + } + + #[test] + fn keys_retaining_too_many_bytes_is_refused() { + // Under the key count cap, but the material of these usable RSA keys exceeds the byte + // cap. `from_jwk` decodes any-length `n`, so this is reachable without the count cap. + let big_n = "A".repeat(2000); + let keys: Vec<_> = (0..40) + .map(|i| serde_json::json!({"kty":"RSA","kid":format!("k{i}"),"n":big_n,"e":"AQAB"})) + .collect(); + let body = serde_json::json!({ "keys": keys }).to_string(); + let err = parse_jwks_keys(body.as_bytes()).unwrap_err().to_string(); + assert!(err.contains("retain more than"), "{err}"); + } + + #[tokio::test] + async fn an_overlong_jwks_url_is_refused() { + // The cache keys on the URL string, so an unbounded URL is refused before it is cached. + // Assert the length error specifically: a bogus URL would fail the fetch regardless. + let url = format!( + "https://issuer.example.com/{}", + "a".repeat(MAX_JWKS_URL_LEN) + ); + let err = fetch_jwks(&url).await.unwrap_err().to_string(); + assert!(err.contains("longer than"), "{err}"); + } + + #[test] + fn an_oversized_pem_is_refused() { + // A well-formed key body padded past the cap: refused for its length before decoding, + // so an oversized-but-valid key cannot be stored and reparsed on every request. + let big = format!( + "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n", + "A".repeat(MAX_GUEST_PEM_LEN) + ); + let err = decoding_key_from_pem(&big).err().unwrap().to_string(); + assert!(err.contains("longer than"), "{err}"); + // Whitespace padding must count: the endpoint stores the untrimmed value, so the cap is + // measured before trimming rather than on the small trimmed key it would otherwise see. + let padded = format!("{}{RSA_PUBLIC}", " ".repeat(MAX_GUEST_PEM_LEN)); + let err = decoding_key_from_pem(&padded).err().unwrap().to_string(); + assert!(err.contains("longer than"), "{err}"); + } + + #[test] + fn key_ops_without_verify_is_refused() { + let enc = jwk(serde_json::json!({"kty":"RSA","key_ops":["encrypt"],"n":"aa","e":"AQAB"})); + assert_eq!(jwk_algorithms(&enc), None); + let ver = jwk(serde_json::json!({"kty":"RSA","key_ops":["verify"],"n":"aa","e":"AQAB"})); + assert_eq!(jwk_algorithms(&ver), Some(RSA_ALGORITHMS.to_vec())); + } + + // PUB1's coordinates and its matching PKCS8 private key, for the JWKS-derived + // verification test. + const PUB1_X: &str = "zAfqyCh34iYOCW0vg4ejq_zzJlzLSZScjnVyPjLGTao"; + const PUB1_Y: &str = "RMKOIHOv8tWLnXf7-eMCodDnX038wCjD1sf9jVsf7oI"; + 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"; + + #[test] + fn a_jwks_key_verifies_a_real_token() { + // The one path the PEM tests do not cover: a key rebuilt from a JWK verifies a + // token signed by its private half, and the algorithms come from the JWK. + let jwk = jwk(serde_json::json!({ + "kty": "EC", "crv": "P-256", "kid": "k1", "x": PUB1_X, "y": PUB1_Y + })); + let algs = jwk_algorithms(&jwk).expect("EC signing key"); + assert_eq!(algs, vec![Algorithm::ES256]); + let key = jsonwebtoken::DecodingKey::from_jwk(&jwk).expect("usable JWK"); + let payload = serde_json::json!({ + "email": "g@example.com", + "workspace_id": "ws", + "app_path": "u/a/app", + "exp": jsonwebtoken::get_current_timestamp() + 600, + }); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &payload, + &jsonwebtoken::EncodingKey::from_ec_pem(PRIV1.as_bytes()).unwrap(), + ) + .unwrap(); + let out = verify(&token, &key, &algs, "ws").expect("verifies"); + assert_eq!(out.email, "g@example.com"); + // The workspace pin is part of verify. + assert!(verify(&token, &key, &algs, "other-ws").is_err()); + } + + #[test] + fn a_non_key_pem_is_rejected() { + assert!(decoding_key_from_pem( + "-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----" + ) + .is_err()); + } + + // A real RSA public key (SPKI). Its private counterpart is RSA_PKCS1_PRIVATE below. + const RSA_PUBLIC: &str = "-----BEGIN PUBLIC KEY-----\n\ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3J0fQcHp2ZlMI4rCVsY\n\ +tirATZPWyPD7exoYWPInhV5xjbY2Fe8IVFaZszQcQbCXZjBtFp2fj0tBTow8BeOy\n\ +X9LJPyKeho/j68FycuDVg7JCzG0TWtsnh/V23WkrlKIfmMqS3+YUyFavROTcAN1T\n\ +5BcFHLAi/4Q2qy0JjXBdZ8avelzZrQ/T67/Kcsoct/pvnEDT2YRsSbA7VMWaxWh8\n\ +MYJ7GNV/10YT2c5CBJGSLbyRSVWk2IwfnM9Cl9n/5NE6TkSetYQ2xlqKTONp5W43\n\ +UzW1NAeqKCxPQfN/ADjwW18nk2o7xj1kMF4rBlhsTm9ClE71nwi5NxsvMOdVxytZ\n\ +ZQIDAQAB\n\ +-----END PUBLIC KEY-----\n"; + + // A complete, valid PKCS#1 RSA *private* key (the counterpart of RSA_PUBLIC) with its + // armor relabelled `RSA PUBLIC KEY`. jsonwebtoken's from_rsa_pem accepts it under that + // label; the structural check refuses it (a 9-field RSAPrivateKey is neither an SPKI nor + // a 2-field RsaPublicKey). Complete on purpose: malformed DER would fail for the wrong + // reason and let a real bypass through unnoticed. + const RSA_PKCS1_PRIVATE_AS_PUBLIC: &str = "-----BEGIN RSA PUBLIC KEY-----\n\ +MIIEogIBAAKCAQEAx3J0fQcHp2ZlMI4rCVsYtirATZPWyPD7exoYWPInhV5xjbY2\n\ +Fe8IVFaZszQcQbCXZjBtFp2fj0tBTow8BeOyX9LJPyKeho/j68FycuDVg7JCzG0T\n\ +Wtsnh/V23WkrlKIfmMqS3+YUyFavROTcAN1T5BcFHLAi/4Q2qy0JjXBdZ8avelzZ\n\ +rQ/T67/Kcsoct/pvnEDT2YRsSbA7VMWaxWh8MYJ7GNV/10YT2c5CBJGSLbyRSVWk\n\ +2IwfnM9Cl9n/5NE6TkSetYQ2xlqKTONp5W43UzW1NAeqKCxPQfN/ADjwW18nk2o7\n\ +xj1kMF4rBlhsTm9ClE71nwi5NxsvMOdVxytZZQIDAQABAoIBAD+IbaQQM7d3Dj/X\n\ +4cyyqJ4K40QzFmXfIfTWXLAkv0MkUR7XzsXQ5YHcLkzgCipAwxGp1m4wWs4OJmkL\n\ +kek8XatZnYLPl9j8iBmm/zqp9Unk5JNzIYm9KwwLvMgOAvRvaopE6WGKTM9+kYls\n\ +L8rUti7/yECZuSRU7Qc9KwBTrWVrXK+RBtBqZYQXb92BFxq0N3Qp+utLNdFcO5sW\n\ +7d8gKp3ipQt5z9ZAB2pYMw7ZTzonF4C7HdyrbYXztvYrxuw1imMkQ9iFFhdn3/76\n\ +qFR7XwaFrld8DECGaH/652kV6zaSQijbBTeXF4zsgwXY4BHMVmZKaXH5unw8Gbmo\n\ +WCoLbLcCgYEA4vTo5kCiKXnlBp3W1Zpg4cml6Wzo0UDldF4kkuXQxhdQA38c0ise\n\ +Cocf6qyAqz1L2TxQ/9WCL2oIP1AY9XqnQ0cJtYIosGWORz4tPe67M8inB/GBotFI\n\ +pmQNVSIjqbgKVi0x+UzmFjitINFPf461lDdJTwhsv9TQRbHrXErvON8CgYEA4PhV\n\ +GYMJu46tqFVtD/koWAQRLmeaZXhxP5lMSmQjdYCa3ys5lccvTlzoF9K8immMQkIx\n\ +gyOazmEtFnK4IXmEY1wg2NIHuJM7/maoM2rozbjXBxsYM7Xw2QX7BHXqG6Ia/Bij\n\ +ZaRJdumCVRJv7OshQTGuqDIzd3l5WEqg11XYgjsCgYAP3v6Wc3ijm+GXN9x5LYWO\n\ +5JIUo8gYMgiZvaejGi0iXSj8RZxXWiqMo+xodc29q9itBVnIuj6TYD/ZZZmJOR2P\n\ +R9128vYzd7aeZsu1JAe1VFfR52KgZzBEaoTAKlYCHVujsR9ohqckcKwyulBr5Cfw\n\ +iHk47KbmN1SlOw7xclAOUwKBgAuxHEsdIk5bFe9fsTFZU51vaK0uuTl4zvntL6fW\n\ +GHms21+p0W5VUcIS1gUW8LGI1r9CzWvxV8RODJfUEnm65QR870AVek0/aajJEQjL\n\ +D5pRdutpnxJg7El7JBaRQj95Z0mexi8sIJ1LeXiOYr6/YZUPzfHz2fTlnUbXahCG\n\ +55+tAoGAI811NTb7kuuIPYuj4raDW88QVNX2xB3+p9lXGolB4jPgsUEjgSvLgH9S\n\ +Q/LwEBiCYVyii8MvWsIZpHvSGyOoty2p19/CAvrAOfpEVlnXQeiX+mh09p1mQbfM\n\ +y9rTR828ADcaZ63Ej1oL4GcqmGhODxCLy1YKKcy0FHzChqPMV6g=\n\ +-----END RSA PUBLIC KEY-----\n"; + + #[test] + fn a_private_pem_is_refused() { + // A verification key must be public and must never be stored otherwise: it is served + // back through the settings response. jsonwebtoken keys the public/private split off + // the PEM label, so private DER relabelled with a public armor slips a label check; + // only parsing the DER as a public-key structure (SPKI or PKCS#1 RSA public) refuses + // it. A real public key still parses, so the guard is not vacuous. + assert!(decoding_key_from_pem(RSA_PUBLIC).is_ok()); + // The same key with its body on one line (not 64-column wrapped): jsonwebtoken accepts + // any wrapping, so the guard must too rather than lean on the strict RFC 7468 decoder. + let body: String = RSA_PUBLIC + .lines() + .filter(|l| !l.starts_with("-----")) + .collect(); + let one_line = format!("-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n"); + assert!(decoding_key_from_pem(&one_line).is_ok()); + + assert!(decoding_key_from_pem(PRIV1).is_err()); + // The same PKCS#8 EC private key, relabelled `PUBLIC KEY`. + assert!(decoding_key_from_pem(&PRIV1.replace("PRIVATE KEY", "PUBLIC KEY")).is_err()); + assert!(decoding_key_from_pem(RSA_PKCS1_PRIVATE_AS_PUBLIC).is_err()); + } + + #[tokio::test] + async fn a_concurrent_cold_burst_makes_one_jwks_fetch() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::AsyncWriteExt; + let _env = TEST_ENV_LOCK.lock().await; + // The stub listens on loopback, which SSRF validation refuses without this. + unsafe { std::env::set_var("ALLOW_PRIVATE_GUEST_JWKS_URLS", "true") }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let body = format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","kid":"k1","x":"{PUB1_X}","y":"{PUB1_Y}"}}]}}"# + ); + let hits = std::sync::Arc::new(AtomicUsize::new(0)); + let hits_srv = hits.clone(); + tokio::spawn(async move { + loop { + let (mut sock, _) = listener.accept().await.unwrap(); + hits_srv.fetch_add(1, Ordering::SeqCst); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + // Delay so the other callers pile onto the single-flight lock before the + // leader's fetch returns. + tokio::time::sleep(Duration::from_millis(150)).await; + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + } + }); + let url = format!("http://127.0.0.1:{}/jwks.json", addr.port()); + let mut handles = Vec::new(); + for _ in 0..10 { + let u = url.clone(); + handles.push(tokio::spawn(async move { + cached_jwks(&u).await.map(|e| e.keys.len()) + })); + } + for h in handles { + assert_eq!( + h.await.unwrap().unwrap(), + 1, + "each caller resolves the one key" + ); + } + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "single-flight: a concurrent cold burst makes one fetch" + ); + unsafe { std::env::remove_var("ALLOW_PRIVATE_GUEST_JWKS_URLS") }; + } + + #[tokio::test] + async fn jwks_fetch_locks_are_shared_and_self_cleaning() { + // The registry is a plain map, not a capacity-bounded cache: a cache could evict a + // lock mid-fetch, letting a later request for that URL start a duplicate fetch. Pin + // both halves of what keeps single-flight intact under many distinct URLs: the same + // URL hands back one shared lock, and the entry is removed once its last holder drops + // (so nothing evicts an in-flight lock and the map stays bounded by fetches in flight). + let url = "https://example.test/jwks-lock-probe.json"; + { + let a = JwksFetchLock::acquire(url); + let b = JwksFetchLock::acquire(url); + assert!(Arc::ptr_eq(&a.lock, &b.lock), "one lock per URL"); + assert!(JWKS_FETCH_LOCKS.lock().unwrap().contains_key(url)); + } + assert!( + !JWKS_FETCH_LOCKS.lock().unwrap().contains_key(url), + "the lock is dropped once idle" + ); + } + + #[tokio::test] + async fn stale_jwks_keys_stop_being_served_past_the_grace_window() { + let _env = TEST_ENV_LOCK.lock().await; + unsafe { std::env::set_var("ALLOW_PRIVATE_GUEST_JWKS_URLS", "true") }; + // A dead loopback port, so every refresh fails (connection refused). + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let jwk = jwk(serde_json::json!( + {"kty":"EC","crv":"P-256","kid":"k1","x":PUB1_X,"y":PUB1_Y} + )); + let keys: HashMap = [("k1".to_string(), jwk)].into_iter().collect(); + let stale = Instant::now().checked_sub(Duration::from_secs(1)).unwrap(); + + // Within the grace window, stale keys are still served while a (failing) refresh runs. + let within = format!("http://127.0.0.1:{port}/within"); + JWKS_CACHE.insert( + within.clone(), + Arc::new(JwksEntry { + keys: Arc::new(keys.clone()), + expires_at: stale, + fetched_at: Instant::now(), + }), + ); + assert!( + cached_jwks(&within).await.is_ok(), + "stale keys within the grace window are still served" + ); + + // Past the grace window, the keys are not served: the failing refresh fails closed. + let beyond = format!("http://127.0.0.1:{port}/beyond"); + JWKS_CACHE.insert( + beyond.clone(), + Arc::new(JwksEntry { + keys: Arc::new(keys), + expires_at: stale, + fetched_at: Instant::now() + .checked_sub(JWKS_MAX_STALE + Duration::from_secs(1)) + .unwrap(), + }), + ); + assert!( + cached_jwks(&beyond).await.is_err(), + "keys past the grace window fail closed once the refresh fails" + ); + unsafe { std::env::remove_var("ALLOW_PRIVATE_GUEST_JWKS_URLS") }; + } + + #[tokio::test] + async fn a_plaintext_http_jwks_url_is_refused_by_default() { + let _env = TEST_ENV_LOCK.lock().await; + unsafe { std::env::remove_var("ALLOW_PRIVATE_GUEST_JWKS_URLS") }; + // The JWKS supplies the keys that authenticate guest JWTs; without the operator opt-in, + // a plaintext URL (which an on-path attacker could replace) is refused for its scheme. + assert!(matches!( + crate::ssrf::validate_guest_jwks_url("http://issuer.example.com/jwks.json").await, + Err(crate::ssrf::SsrfValidationError::HttpsRequired) + )); + } + + #[tokio::test] + async fn the_instance_issuer_bypasses_the_https_and_private_restriction() { + let _env = TEST_ENV_LOCK.lock().await; + unsafe { std::env::remove_var("ALLOW_PRIVATE_GUEST_JWKS_URLS") }; + // The instance issuer is operator-trusted (it also backs jwt_ext_), so an http/private + // URL is not refused for its scheme: it reaches the connect and fails there (dead port), + // not at validation. A different URL is not the instance issuer and is still refused. + let instance = "http://127.0.0.1:1/jwks.json"; + unsafe { std::env::set_var("JWT_EXT_JWKS_URL", instance) }; + let trusted = fetch_jwks(instance).await.err().unwrap().to_string(); + let other = fetch_jwks("http://127.0.0.1:1/other.json") + .await + .err() + .unwrap() + .to_string(); + unsafe { std::env::remove_var("JWT_EXT_JWKS_URL") }; + assert!( + !trusted.contains("not allowed") && !trusted.contains("must use https"), + "instance issuer skips validation: {trusted}" + ); + assert!( + other.contains("not allowed") || other.contains("https"), + "a non-instance http url is still refused: {other}" + ); + } +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index b4128f80d2..7fb5e85a69 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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; diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 4c50f597ff..713b2e0289 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -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 Result { + 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 { let parsed = url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; diff --git a/backend/windmill-common/src/users.rs b/backend/windmill-common/src/users.rs index 925fe32a4a..3d00c5d312 100644 --- a/backend/windmill-common/src/users.rs +++ b/backend/windmill-common/src/users.rs @@ -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 { + 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 diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 697fc69e44..b6d10cb7df 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -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 { + 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 { + 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 { + let instance_admits = instance_admits_guests_sql(); + let stands: Option = 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 { + // 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 = 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); + } +} diff --git a/backend/windmill-queue/Cargo.toml b/backend/windmill-queue/Cargo.toml index b9bd6fd28e..6dc5d8ba42 100644 --- a/backend/windmill-queue/Cargo.toml +++ b/backend/windmill-queue/Cargo.toml @@ -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"] diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 8ac4fd390a..5bb9a1120f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -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, 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, diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 0390e1c366..e75afc053e 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -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"] diff --git a/backend/windmill-worker/loader.py b/backend/windmill-worker/loader.py index ab10c36b86..bf67266d87 100644 --- a/backend/windmill-worker/loader.py +++ b/backend/windmill-worker/loader.py @@ -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: diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 77221edf22..7c112f5985 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -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, job: &MiniPulledJob, conn: &Connection, + canceled_by: &mut Option, modules: &Option>, preprocessed_args: Option<&HashMap>>, ) -> error::Result> { 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 {}", diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 883b035a32..18808672fc 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -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 = 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()); diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 3e9b7bbc9b..cecf0e316c 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -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), + /// 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 { + // `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, +) -> 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, +) { + #[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 { serde_json::from_str(result.get()) diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 6a165901e4..12930e8de4 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -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 { return await windmill.UserService.login({ diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 7772dafedd..0f46ec76be 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -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; diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 9f83d0322a..901a47593f 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -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; diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index bab9967b3c..be5d9e5639 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -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 diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 7017136a66..35bd6bc2e7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -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"; diff --git a/cli/test/app_access_mode_unit.test.ts b/cli/test/app_access_mode_unit.test.ts new file mode 100644 index 0000000000..12185d6d99 --- /dev/null +++ b/cli/test/app_access_mode_unit.test.ts @@ -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"); +}); diff --git a/docs/docker-security.md b/docs/docker-security.md index f0a9f23073..5b5196f8b4 100644 --- a/docs/docker-security.md +++ b/docs/docker-security.md @@ -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: +``` + +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: \ + --format '{{ json .SBOM }}' +``` + +Verify SLSA provenance through GitHub's attestation API: + +```bash +gh attestation verify oci://ghcr.io/windmill-labs/windmill: \ + -R windmill-labs/windmill +``` + +Note for registry housekeeping: cosign stores signatures as extra +`sha256-.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. diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 5cef3c9364..f5ce2357ca 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6fd6413d59..7dde8c270e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index f8f952ede6..593ad0e404 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 760f4c0261..f6dc648234 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -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)); } diff --git a/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte b/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte index 6fa87abf64..a9706ecc75 100644 --- a/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte +++ b/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte @@ -6,30 +6,34 @@ import { createEventDispatcher } from 'svelte' interface Props { - email: string; - username: string; - isConflict?: boolean; - noPadding?: boolean; + email: string + username: string + isConflict?: boolean + noPadding?: boolean } - let { - email, - username = $bindable(), - isConflict = false, - noPadding = false - }: Props = $props(); + let { email, username = $bindable(), isConflict = false, noPadding = false }: Props = $props() let loading = $state(false) - let usernameInfo: - | { - username: string - workspace_usernames: { - workspace_id: string - username: string - }[] - } - | undefined = $state(undefined) + type UsernameInfo = { + username: string + workspace_usernames: { + workspace_id: string + username: string + }[] + } + + let usernameInfo: UsernameInfo | undefined = $state(undefined) + + let affectedWorkspaces = $derived.by( + () => usernameInfo?.workspace_usernames.filter((w) => w.username !== username) ?? [] + ) + let isRenaming = $derived.by( + () => + usernameInfo !== undefined && + (username !== usernameInfo.username || affectedWorkspaces.length > 0) + ) function handleKeyUp(event: KeyboardEvent) { const key = event.key @@ -53,6 +57,11 @@ const dispatch = createEventDispatcher() async function renameUser() { + // Renaming before the current usernames are known would apply a change whose scope + // the "Manual action required" warning could not have been shown for. + if (!usernameInfo) { + return + } loading = true try { const automateUsernameCreation = @@ -102,31 +111,30 @@ Users are required to have an instance-wide username that is shared across all workspaces. However, this user has different usernames in different workspaces. - {#if usernameInfo?.workspace_usernames && usernameInfo.workspace_usernames.filter((w) => w.username !== username).length > 0} + {#if affectedWorkspaces.length > 0}

- Workspaces requiring username modification: {usernameInfo.workspace_usernames - .filter((w) => w.username !== username) + Workspaces requiring username modification: {affectedWorkspaces .map((wu) => `${wu.workspace_id} (${wu.username})`) .join(', ')} {/if} {/if} - {#if !isConflict && usernameInfo?.workspace_usernames && usernameInfo.workspace_usernames.filter((w) => w.username !== username).length > 0} + {#if !isConflict && affectedWorkspaces.length > 0} - {usernameInfo.workspace_usernames - .filter((w) => w.username !== username) - .map((wu) => `${wu.workspace_id}`) - .join(', ')} + {affectedWorkspaces.map((wu) => `${wu.workspace_id}`).join(', ')} {/if} - - This operation does not handle references in scripts, workflows and applications to scripts in - the workspace, and references in resources to variables. You will have to handle those manually. -
-
+ {#if isRenaming} + + This operation does not handle references in scripts, workflows and applications to scripts in + the workspace, and references in resources to variables. You will have to handle those + manually. +
+
+ {/if} diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index e74154b406..97b15fe443 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -1,5 +1,6 @@ + + + +
+ {#key usage} + setInstanceSwitch(e.detail)} + options={{ + right: 'Allow guests on this instance', + rightTooltip: + 'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.' + }} + /> + {/key} +
+ +
+ + {#if usage.metered} + Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0 + ? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now` + : ''}. + {:else} + Beyond the allowance, new guests are refused until the count drops below it; an + Enterprise license meters them instead. + {/if} + +
+ + onLoadMore()} +> + + + Email + Workspaces + First seen + Last seen + + + + {#each guests as guest, i (guest.email)} + + {guest.email} + {guest.workspaces.join(', ')} + {guest.first_seen} + {guest.last_seen} + + {/each} + + diff --git a/frontend/src/lib/components/markdownProse.ts b/frontend/src/lib/components/markdownProse.ts index bc943aa42b..aedd2f45b1 100644 --- a/frontend/src/lib/components/markdownProse.ts +++ b/frontend/src/lib/components/markdownProse.ts @@ -22,13 +22,13 @@ const base = // One vertical rhythm for sm/doc; heading margins stay per-preset (fixed, not // the plugin's em-based ones) so 'doc' can breathe more between sections. -const rhythm = 'prose-sm leading-snug prose-ul:!pl-6' +const rhythm = 'prose-sm leading-snug' const bodyXs = 'text-primary prose-p:text-primary prose-li:text-primary prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs prose-table:text-xs' export const markdownProse = { - xs: `${base} prose-sm leading-snug prose-ul:!pl-5 prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`, + xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`, sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`, doc: `${base} ${rhythm} ${bodyXs} prose-headings:mt-8 prose-headings:mb-2 prose-headings:font-semibold prose-headings:text-emphasis prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs prose-pre:bg-transparent prose-pre:p-0` } as const diff --git a/frontend/src/lib/components/runs/RunRow.svelte b/frontend/src/lib/components/runs/RunRow.svelte index 27341d7743..959ee2fd06 100644 --- a/frontend/src/lib/components/runs/RunRow.svelte +++ b/frontend/src/lib/components/runs/RunRow.svelte @@ -127,6 +127,8 @@ {/if} {:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)} Waiting executor () + {:else if 'running' in job && job.running && job.suspend} + Suspended (created ) {:else} Waiting executor () {/if} diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index f2e2ca235d..fe85d85f03 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -56,6 +56,7 @@ if (!label) return true return ( label !== 'session' && + label !== 'guest_session' && !label.toLowerCase().startsWith('ephemeral') && label !== 'debugger-token' && !label.startsWith('mcp-oauth-') diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index 3495bdbee0..0300ddf310 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -34,6 +34,7 @@ import DataTableConnectionReport from './DataTableConnectionReport.svelte' import { useSupabaseOauth } from './supabaseOauth.svelte' import { probeDatatableConnection } from './datatableProbe' + import { logDatatableWizard } from './datatableTelemetry' import { anythingClaimed, claimOf, @@ -526,11 +527,13 @@ await loadTargetUser() reset(parked ?? resume) opened = true + logDatatableWizard({ step: 'opened' }) } function selectProvider(key: Provider) { if (key === wiz.provider) return wiz.provider = key + logDatatableWizard({ step: 'picked', provider: key }) invalidate() if (key === 'instance') wiz.instance.dbName ??= defaultInstanceDbName() } @@ -833,6 +836,11 @@ createdProjects } } + // The setup's own verdict, so a data table that exists counts as done even when the + // caller's appended `onFinishAlso` step failed after it. + if (wiz.provider) { + logDatatableWizard({ step: result?.ok ? 'done' : 'failed', provider: wiz.provider }) + } onDone() } } diff --git a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte index be6a240c19..eefc1d5647 100644 --- a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte +++ b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte @@ -43,6 +43,7 @@ let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers')) let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let restrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing')) + let restrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment')) let selectedGroups = $state( untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? [] ) @@ -57,6 +58,7 @@ let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers')) let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let initialRestrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing')) + let initialRestrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment')) let initialSelectedGroups = $state( untrack(() => rule)?.bypass_groups ? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', '')) @@ -125,6 +127,7 @@ restrictDeployToDeployers || restrictAnonymousAppDeployment || restrictPublicRunSharing || + restrictGuestAppDeployment || selectedGroups.length > 0 || selectedUsers.length > 0 : name !== initialName || @@ -133,6 +136,7 @@ restrictDeployToDeployers !== initialRestrictDeployToDeployers || restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment || restrictPublicRunSharing !== initialRestrictPublicRunSharing || + restrictGuestAppDeployment !== initialRestrictGuestAppDeployment || JSON.stringify([...selectedGroups].sort()) !== JSON.stringify([...initialSelectedGroups].sort()) || JSON.stringify([...selectedUsers].sort()) !== @@ -176,7 +180,10 @@ ...(restrictAnonymousAppDeployment ? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind] : []), - ...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []) + ...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []), + ...(restrictGuestAppDeployment + ? ['RestrictGuestAppDeployment' as ProtectionRuleKind] + : []) ], bypass_groups: selectedGroups, bypass_users: selectedUsers @@ -209,7 +216,10 @@ ...(restrictAnonymousAppDeployment ? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind] : []), - ...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []) + ...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []), + ...(restrictGuestAppDeployment + ? ['RestrictGuestAppDeployment' as ProtectionRuleKind] + : []) ], bypass_groups: selectedGroups, bypass_users: selectedUsers @@ -225,6 +235,7 @@ initialRestrictDeployToDeployers = restrictDeployToDeployers initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment initialRestrictPublicRunSharing = restrictPublicRunSharing + initialRestrictGuestAppDeployment = restrictGuestAppDeployment initialSelectedGroups = clone(selectedGroups) initialSelectedUsers = clone(selectedUsers) @@ -379,6 +390,21 @@ + +
+ +
+ Only workspace admins and bypass users can open an app to guests (anyone who signs in, + with no workspace membership and no seat). Apps that already admit guests can still be + redeployed. +
+
+
{#if !redirectUriValid} -

Error: invalid or unsafe redirect_uri

+ +

Error: invalid or unsafe redirect_uri

+
{:else if !isGateway && !workspaceId} -

Error: missing workspace_id

+ +

Error: missing workspace_id

+
{:else} {#if success} diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/layoutReset.test.ts b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/layoutReset.test.ts new file mode 100644 index 0000000000..6ba4469e27 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/layoutReset.test.ts @@ -0,0 +1,14 @@ +import { readdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +// Renaming the page back drops it into the (logged) layout, where it hangs on +// "Loading user..." with no type error and no other failing test — see the page header. +const routeDir = dirname(fileURLToPath(import.meta.url)) + +describe('mcp oauth consent route', () => { + it('escapes the (logged) layout', () => { + expect(readdirSync(routeDir)).toContain('+page@(root).svelte') + }) +}) diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 8dd2937273..8590d87603 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -28,6 +28,7 @@ type InstanceAISummary, type GetSettingsResponse } from '$lib/gen' + import type { GuestUsage } from '$lib/gen' import { enterpriseLicense, superadmin, @@ -187,6 +188,22 @@ let criticalAlertUIMuted: boolean | undefined = $state(undefined) let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined) let publicAppRateLimitPerMinute: number | undefined = $state(undefined) + let guestAccessEnabled: boolean = $state(false) + let guestUsage: GuestUsage | undefined = $state(undefined) + let initialGuestAccessEnabled: boolean = $state(false) + // A guest JWT is verified against one key: a PEM public key, or a JWKS URL. The + // type picks which field is live; the other is cleared on save. + let guestJwtKeyType = $state<'pem' | 'jwks'>('pem') + let guestJwtPublicKey: string = $state('') + let guestJwtJwksUrl: string = $state('') + let initialGuestJwtPublicKey: string = $state('') + let initialGuestJwtJwksUrl: string = $state('') + // The pair actually saved: only the selected type's field, trimmed. The unselected + // one is empty, so switching type and saving clears what was there. + let effectiveGuestJwt = $derived({ + pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '', + jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : '' + }) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) let hasInstanceAiConfig = $state(false) @@ -522,6 +539,17 @@ } async function saveDefaultAppSettings(): Promise { + // Guest access and the guest JWT key are the writes of this card available on every plan; + // save them first so a refused Enterprise-only write after cannot swallow them. + if (guestAccessEnabled !== initialGuestAccessEnabled) { + await editGuestAccess() + } + if ( + effectiveGuestJwt.pem !== initialGuestJwtPublicKey || + effectiveGuestJwt.jwks !== initialGuestJwtJwksUrl + ) { + await editGuestJwtKey() + } if (workspaceDefaultAppPath !== initialWorkspaceDefaultAppPath) { await editWorkspaceDefaultApp() } @@ -530,6 +558,32 @@ } } + async function editGuestJwtKey(): Promise { + await WorkspaceService.editGuestJwtKey({ + workspace: $workspaceStore!, + requestBody: { + public_key: effectiveGuestJwt.pem || undefined, + jwks_url: effectiveGuestJwt.jwks || undefined + } + }) + initialGuestJwtPublicKey = effectiveGuestJwt.pem + initialGuestJwtJwksUrl = effectiveGuestJwt.jwks + sendUserToast('Guest JWT key updated') + } + + async function editGuestAccess(): Promise { + await WorkspaceService.editGuestAccess({ + workspace: $workspaceStore!, + requestBody: { guest_access_enabled: guestAccessEnabled } + }) + initialGuestAccessEnabled = guestAccessEnabled + sendUserToast( + guestAccessEnabled + ? 'Guests can now open apps set to Guests in this workspace' + : 'Guests can no longer sign in to this workspace' + ) + } + async function loadWorkspaceEncryptionKey(): Promise { let resp = await WorkspaceService.getWorkspaceEncryptionKey({ workspace: $workspaceStore! @@ -623,6 +677,16 @@ initialCriticalAlertUIMuted = settings.mute_critical_alerts publicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined + guestAccessEnabled = settings.guest_access_enabled ?? false + initialGuestAccessEnabled = settings.guest_access_enabled ?? false + guestJwtPublicKey = settings.guest_jwt_public_key ?? '' + guestJwtJwksUrl = settings.guest_jwt_jwks_url ?? '' + initialGuestJwtPublicKey = guestJwtPublicKey + initialGuestJwtJwksUrl = guestJwtJwksUrl + guestJwtKeyType = guestJwtJwksUrl ? 'jwks' : 'pem' + WorkspaceService.getGuestUsage({ workspace: $workspaceStore! }) + .then((u) => (guestUsage = u)) + .catch(() => (guestUsage = undefined)) if (emptyString($enterpriseLicense)) { errorHandlerSelected = 'custom' } else if ( @@ -1024,11 +1088,17 @@ return { savedValue: { defaultAppPath: initialWorkspaceDefaultAppPath, - publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute + publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute, + guestAccessEnabled: initialGuestAccessEnabled, + guestJwtPem: initialGuestJwtPublicKey, + guestJwtJwks: initialGuestJwtJwksUrl }, modifiedValue: { defaultAppPath: workspaceDefaultAppPath, - publicAppRateLimitPerMinute: publicAppRateLimitPerMinute + publicAppRateLimitPerMinute: publicAppRateLimitPerMinute, + guestAccessEnabled: guestAccessEnabled, + guestJwtPem: effectiveGuestJwt.pem, + guestJwtJwks: effectiveGuestJwt.jwks } } } @@ -1037,6 +1107,10 @@ function discardDefaultAppSettingsChanges() { workspaceDefaultAppPath = initialWorkspaceDefaultAppPath publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute + guestAccessEnabled = initialGuestAccessEnabled + guestJwtPublicKey = initialGuestJwtPublicKey + guestJwtJwksUrl = initialGuestJwtJwksUrl + guestJwtKeyType = initialGuestJwtJwksUrl ? 'jwks' : 'pem' } // Strip keys from extraArgs that are auto-managed by child components: @@ -2152,13 +2226,94 @@ export async function main( executions per minute per server + + + {#if guestUsage && !guestUsage.instance_enabled} + + A superadmin has turned guests off for this instance, so this switch has no + effect until they are allowed again. + + {:else if guestUsage} + + {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this + instance in the last {guestUsage.window_days} days. + {#if guestUsage.metered} + Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0 + ? ` (${guestUsage.guest_seats} now)` + : ''}. + {:else} + Beyond that, new guests are refused until the count drops; an Enterprise + license meters them instead. + {/if} + + {/if} +
+
+ Guest JWT verification key +
+
+ A guest can also enter through a JWT your own backend mints and signs, with no + identity-provider round-trip, for iframe embedding. The token must carry + email, workspace_id, app_path and + exp (lifetime capped at 24h); it opens only the app named by + app_path. Accepted algorithms: RS256/384/512, PS256/384/512, + ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM + public key or a JWKS URL (which must be https). Point it at an issuer you + control: any token that key signs carrying these claims is accepted, so a shared + multi-tenant issuer is not a good fit. +
+ + {#snippet children({ item })} + + + {/snippet} + + {#if guestJwtKeyType === 'pem'} + + {:else} + + {/if} + {#if !isCloudHosted()} +
+ Leave empty to fall back to the instance's configured JWT issuer (JWT_EXT_JWKS_URL), if one is set. Set a key here to trust a different issuer for this + workspace. +
+ {/if} +
+
+ {:else if tab == 'native_triggers'} {#if $workspaceStore} diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 0e404afbcc..4de44442a2 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -147,7 +147,10 @@ } else { if ( (!page.url.pathname.startsWith('/user/') || page.url.pathname.startsWith('/user/cli')) && - !page.url.pathname.startsWith('/oauth/mcp_authorize') && + // The MCP consent page carries its own workspace picker, so it is left to + // run without one. Nothing sets `$userStore` on this branch, which is why + // that page must stay outside the (logged) layout — see its `@(root)` name. + !page.url.pathname.startsWith(`${base}/oauth/mcp_authorize`) && // The hub import wizard asks for the destination itself, and may end in a // workspace that does not exist yet — bouncing it to the picker would // force the very choice it exists to make. diff --git a/frontend/src/routes/a/[...path]/+page.svelte b/frontend/src/routes/a/[...path]/+page.svelte index fc2da2b619..e9fd20877c 100644 --- a/frontend/src/routes/a/[...path]/+page.svelte +++ b/frontend/src/routes/a/[...path]/+page.svelte @@ -19,30 +19,46 @@ let jwtError = $state(false) function isJwt(t: string) { - // simply check that the first part is a valid base64 encoded json + // A JWT is three dot-separated base64url segments; check the header decodes to + // JSON. `atob` wants standard base64, so normalise base64url first (a `kid` or a + // signature routinely contains `-`/`_`), or a valid token is taken for a path. try { const parts = t.split('.') - const header = atob(parts[0]) - JSON.parse(header) + if (parts.length !== 3) return false + const b64 = parts[0].replace(/-/g, '+').replace(/_/g, '/') + const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)) + JSON.parse(atob(b64 + pad)) return true } catch (e) { return false } } - function parseCustomPath(customPath: string): { path: string; jwt: string | undefined } { + // The custom path may carry a trailing credential: an external JWT as its last + // segment, or a guest JWT in a `guest.` last segment (`/guest.`). The + // `guest.` prefix keeps the two apart; `viewerUrl` uses `path` alone, so neither + // reaches the opaque iframe. + function parseCustomPath(customPath: string): { + path: string + jwt: string | undefined + guestJwt: string | undefined + } { const parts = customPath.split('/') - if (parts.length > 1 && isJwt(parts[parts.length - 1])) { + const last = parts[parts.length - 1] + // A guest JWT rides the last segment prefixed `guest.`. The `.` means it can never + // be a valid custom-path segment, so a real path ending in a `guest` segment + // followed by an external JWT (`.../guest/`) is read as before, not hijacked. + if (last.startsWith('guest.') && isJwt(last.slice('guest.'.length))) { return { path: parts.slice(0, -1).join('/'), - jwt: parts[parts.length - 1] - } - } else { - return { - path: customPath, - jwt: undefined + jwt: undefined, + guestJwt: last.slice('guest.'.length) } } + if (parts.length > 1 && isJwt(last)) { + return { path: parts.slice(0, -1).join('/'), jwt: last, guestJwt: undefined } + } + return { path: customPath, jwt: undefined, guestJwt: undefined } } const parsedCustomPath = parseCustomPath(page.params.path ?? '') @@ -58,11 +74,53 @@ let workspace: string | undefined = $state(undefined) let refresh: (() => void) | undefined + /** `/` when this app is open to guests. Resolved eagerly: + * PublicAppFrame renders its sign-in gate before `onViewerReady` fires. */ + let guestAppPath: string | undefined = $state(undefined) + /** The frame's sign-in card must not mount before this is known: a configured + * auto-login would otherwise fire an ordinary sign-in and provision an account. + * Only the card waits — the app load itself runs in parallel with discovery. + * Only a confirmed 404 means "not a guest app"; any other failure is `error`, since + * offering an ordinary sign-in on a transient fault would provision an account. */ + let guestEntry: 'pending' | 'none' | 'guest' | 'error' = $state('pending') + + // Settled once: `loadApp` calls this again on failure, and a later transient fault + // must not overwrite an answer already in hand. A function, not a narrowed local: + // the value changes across the awaits below. + const guestEntrySettled = () => guestEntry === 'guest' || guestEntry === 'none' + async function loadGuestEntry() { + if (guestEntrySettled()) return + for (let attempt = 0; attempt < 3; attempt++) { + try { + const entry = await AppService.getGuestEntryByCustomPath({ + customPath: parsedCustomPath.path + }) + guestAppPath = `${entry.workspace_id}/${entry.app_path}` + guestEntry = 'guest' + return + } catch (e) { + if (e?.status === 404) { + guestAppPath = undefined + guestEntry = 'none' + return + } + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))) + // A concurrent call may have settled it meanwhile. + if (guestEntrySettled()) return + } + } + if (!guestEntrySettled()) { + guestAppPath = undefined + guestEntry = 'error' + } + } // Embedder side: validate access (main session cookie or shared JWT) and mint // a scoped embed token for the opaque iframe (WIN-2006). async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> { - if (parsedCustomPath.jwt) { + if (parsedCustomPath.guestJwt) { + OpenAPI.TOKEN = 'jwt_guest_' + parsedCustomPath.guestJwt + } else if (parsedCustomPath.jwt) { OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt } const headers: Record = {} @@ -113,17 +171,27 @@ } else { notExists = true } + // The app exists and admits guests; the load failed only for want of a + // session, so offer one instead of the not-found page. + await loadGuestEntry() + if (guestAppPath) { + notExists = false + noPermission = true + } } } if (BROWSER) { setLicense() + loadGuestEntry() } { refresh = requestTokenRefresh loadApp() @@ -135,6 +203,7 @@ {notExists} {noPermission} {jwtError} + {guestAppPath} {app} onLoginSuccess={() => loadApp()} > diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index 1f142590d9..b2a29b6316 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -16,13 +16,36 @@ let notExists = $state(false) let noPermission = $state(false) let jwtError = $state(false) + /** `/` when this app is open to guests, so the sign-in card can + * offer a guest session rather than a dead end. 404 (the common case) leaves it + * undefined. */ + let guestAppPath: string | undefined = $state(undefined) + /** The frame's sign-in card must not mount before this is known: a configured + * auto-login would otherwise fire an ordinary sign-in and provision an account. + * Only the card waits — the app load itself runs in parallel with discovery. + * Only a confirmed 404 means "not a guest app"; any other failure is `error`, since + * offering an ordinary sign-in on a transient fault would provision an account. */ + let guestEntry: 'pending' | 'none' | 'guest' | 'error' = $state('pending') - function parseSecret(secret: string): { secret: string; jwt: string | undefined } { + // The share link carries a trailing credential the embedder consumes: an external + // JWT as `/`, or a guest JWT as `/guest.`. The `guest.` + // prefix keeps the two apart with no parsing of the token, which the page cannot + // verify anyway. Either way `viewerUrl` below uses `secret` alone, so no JWT + // reaches the opaque iframe. + function parseSecret(secret: string): { + secret: string + jwt: string | undefined + guestJwt: string | undefined + } { const parts = secret.split('/') - return { - secret: parts[0], - jwt: parts[1] + // The credential rides the segment after the secret: a guest JWT prefixed + // `guest.`, or an external JWT bare. The `guest.` prefix glues the marker to the + // token, so it can never be mistaken for a path or secret segment (which carry no + // `.`), and a bare token keeps the established external-JWT interpretation. + if (parts[1]?.startsWith('guest.')) { + return { secret: parts[0], jwt: undefined, guestJwt: parts[1].slice('guest.'.length) } } + return { secret: parts[0], jwt: parts[1], guestJwt: undefined } } const parsedSecret = parseSecret(page.params.secret ?? '') @@ -42,7 +65,9 @@ // Embedder side: validate access (using the main session cookie or the shared // JWT) and mint a scoped embed token for the opaque iframe (WIN-2006). async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> { - if (parsedSecret.jwt) { + if (parsedSecret.guestJwt) { + OpenAPI.TOKEN = 'jwt_guest_' + parsedSecret.guestJwt + } else if (parsedSecret.jwt) { OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt } const headers: Record = {} @@ -83,9 +108,53 @@ } else { notExists = true } + // The app exists and admits guests; the load failed only for want of a + // session, so offer one instead of the not-found page. + await loadGuestEntry() + if (guestAppPath) { + notExists = false + noPermission = true + } } } + // Settled once: `loadApp` calls this again on failure, and a later transient fault + // must not overwrite an answer already in hand. A function, not a narrowed local: + // the value changes across the awaits below. + const guestEntrySettled = () => guestEntry === 'guest' || guestEntry === 'none' + async function loadGuestEntry() { + if (guestEntrySettled()) return + for (let attempt = 0; attempt < 3; attempt++) { + try { + const entry = await AppService.getGuestEntry({ workspace, path: parsedSecret.secret }) + guestAppPath = `${workspace}/${entry.app_path}` + guestEntry = 'guest' + return + } catch (e) { + if (e?.status === 404) { + guestAppPath = undefined + guestEntry = 'none' + return + } + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))) + // A concurrent call may have settled it meanwhile. + if (guestEntrySettled()) return + } + } + if (!guestEntrySettled()) { + guestAppPath = undefined + guestEntry = 'error' + } + } + + // Eager, not on the failure path: PublicAppFrame asks for the embed token and + // renders its own sign-in gate before `onViewerReady` ever fires, so resolving + // this only after a failed `loadApp` would be too late for the case that matters + // most — a signed-out visitor. + if (BROWSER) { + loadGuestEntry() + } + if (BROWSER) { setLicense() } @@ -94,6 +163,8 @@ { refresh = requestTokenRefresh loadApp() @@ -106,6 +177,7 @@ {notExists} {noPermission} {jwtError} + {guestAppPath} onLoginSuccess={() => loadApp()} > {/snippet} diff --git a/frontend/src/routes/user/login_callback/[client_name]/+page.svelte b/frontend/src/routes/user/login_callback/[client_name]/+page.svelte index 3d4a4adf7a..e45b43b871 100644 --- a/frontend/src/routes/user/login_callback/[client_name]/+page.svelte +++ b/frontend/src/routes/user/login_callback/[client_name]/+page.svelte @@ -32,7 +32,7 @@ if (error) { sendUserToast(`Error trying to login with ${clientName} ${error}`, true) if (closeUponLogin) { - goto('/user/close') + closeUponLoginError(`Error trying to login with ${clientName} ${error}`) return } await logoutWithRedirect(rd ?? undefined) @@ -41,7 +41,7 @@ await UserService.loginWithOauth({ requestBody: { code, state }, clientName }) } catch (e) { if (closeUponLogin) { - goto('/user/close') + closeUponLoginError(e.body ?? e.message) return } await logoutWithRedirect(rd ?? undefined) @@ -140,7 +140,16 @@ applyDarkModeVariant() function closeUponLoginSuccess() { - const message = { type: 'success' } + relayToOpener({ type: 'success' }) + } + + /** The popup is the only window that saw the server's answer, and it closes: a + * refusal that stayed here would leave the page that opened it with nothing to show. */ + function closeUponLoginError(error: string) { + relayToOpener({ type: 'error', error: typeof error === 'string' ? error : String(error) }) + } + + function relayToOpener(message: { type: 'success' } | { type: 'error'; error: string }) { if (window.opener) { window.opener.postMessage(message, '*') } else { diff --git a/lsp/Pipfile b/lsp/Pipfile index 0bc31bcb41..0015ac3510 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.803.0" +wmill = ">=1.804.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index bdf8ecce08..3e2a3950f4 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.803.0 + version: 1.804.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 089f7ff05a..aa3bfc30a3 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.803.0' + ModuleVersion = '1.804.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 35a2b8d857..39d53054cb 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.803.0" +version = "1.804.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index f148f1f03c..7d5452bf42 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.803.0", + "version": "1.804.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 4970364ccd..decf7d342f 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.803.0", + "version": "1.804.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index e5952c62ae..494531e9d9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.803.0 +1.804.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index d8c4b1e0fd..fd43bd29a1 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.803.0", + "version": "1.804.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.803.0", + "version": "1.804.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 8245569685..da7c582f9e 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.803.0", + "version": "1.804.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",