mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
Merge remote-tracking branch 'origin/main' into remove-workspace-drafts
This commit is contained in:
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ VERSION=$1
|
||||
echo "Updating versions to: $VERSION"
|
||||
|
||||
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
|
||||
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/src/main.ts
|
||||
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/src/core/constants.ts
|
||||
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
|
||||
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
|
||||
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
|
||||
|
||||
@@ -7,7 +7,7 @@ VERSION=$1
|
||||
echo "Updating versions to: $VERSION"
|
||||
|
||||
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
|
||||
sed -i -e "/^export const VERSION =/s/= .*/= \"$VERSION\";/" ${root_dirpath}/cli/src/main.ts
|
||||
sed -i -e "/^export const VERSION =/s/= .*/= \"$VERSION\";/" ${root_dirpath}/cli/src/core/constants.ts
|
||||
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
|
||||
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
|
||||
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.9.24"
|
||||
version: "0.9.25"
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
node-version: "20"
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.9.24"
|
||||
version: "0.9.25"
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.3"
|
||||
|
||||
@@ -106,3 +106,4 @@ $NAV --root backend callees "X" # what does X call?
|
||||
- Search for existing code to reuse before writing new code
|
||||
- Follow established patterns in the codebase
|
||||
- Keep changes focused — don't refactor beyond what's asked
|
||||
- **Never attribute work to a specific customer, account, or "requested by a customer" in repo-tracked content** (PR descriptions, commit messages, code comments, docs). Describe changes by their technical motivation instead.
|
||||
|
||||
+116
@@ -1,5 +1,121 @@
|
||||
# Changelog
|
||||
|
||||
## [1.719.0](https://github.com/windmill-labs/windmill/compare/v1.718.0...v1.719.0) (2026-06-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **otel:** connect jobs to the inbound distributed trace ([#9456](https://github.com/windmill-labs/windmill/issues/9456)) ([fad1a54](https://github.com/windmill-labs/windmill/commit/fad1a549d95c00d0746a48163c4f95fc69733e1a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* authenticate slack callback payload with per-workspace hmac ([#9461](https://github.com/windmill-labs/windmill/issues/9461)) ([fbdf81b](https://github.com/windmill-labs/windmill/commit/fbdf81ba5f77d282c025360ecee14138dd4cb4a2))
|
||||
* prevent token label collision bypassing job read access control ([#9462](https://github.com/windmill-labs/windmill/issues/9462)) ([e1e7af6](https://github.com/windmill-labs/windmill/commit/e1e7af6a25a44eb06b67332ce1efeae2a21e0c6d))
|
||||
* **python:** escape reserved-keyword step ids in wrapper codegen ([#9460](https://github.com/windmill-labs/windmill/issues/9460)) ([6a15a9b](https://github.com/windmill-labs/windmill/commit/6a15a9b152ad20be4b5c3de6000516da231e41e0)), closes [#8893](https://github.com/windmill-labs/windmill/issues/8893)
|
||||
|
||||
## [1.718.0](https://github.com/windmill-labs/windmill/compare/v1.717.1...v1.718.0) (2026-06-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **flows:** opt-in to include the stopping step's result in early-stop errors ([#9446](https://github.com/windmill-labs/windmill/issues/9446)) ([f2f0812](https://github.com/windmill-labs/windmill/commit/f2f0812a04c9256cfc8eba5e0dcf38d71d971410))
|
||||
* make C# dotnet target framework configurable via DOTNET_TARGET_FRAMEWORK ([#9454](https://github.com/windmill-labs/windmill/issues/9454)) ([9a609bf](https://github.com/windmill-labs/windmill/commit/9a609bf08ac1b6157dbdfb827fc01e771d71262e))
|
||||
* sandboxed daemonless container runtime via '# sandbox <image>' ([#9453](https://github.com/windmill-labs/windmill/issues/9453)) ([1727271](https://github.com/windmill-labs/windmill/commit/1727271e197b34026efeaf1b6561bb404a440baa))
|
||||
* **sandbox:** pull/extract images with crane instead of podman ([#9455](https://github.com/windmill-labs/windmill/issues/9455)) ([7590b28](https://github.com/windmill-labs/windmill/commit/7590b281085afd1fc2774e8fb37a4c0af3aedbad))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* distinguish canceled jobs in runs ([#9452](https://github.com/windmill-labs/windmill/issues/9452)) ([9067787](https://github.com/windmill-labs/windmill/commit/90677872f6185eb0c81e0e84a426a54653818457))
|
||||
|
||||
## [1.717.1](https://github.com/windmill-labs/windmill/compare/v1.717.0...v1.717.1) (2026-06-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalidate relative-import cache when imported script changes ([#9443](https://github.com/windmill-labs/windmill/issues/9443)) ([f595787](https://github.com/windmill-labs/windmill/commit/f595787409a3fcda9278bbcf2cfcc80092f16460))
|
||||
|
||||
## [1.717.0](https://github.com/windmill-labs/windmill/compare/v1.716.0...v1.717.0) (2026-06-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* let flow AI chat create and edit sticky notes ([#9412](https://github.com/windmill-labs/windmill/issues/9412)) ([e4e0984](https://github.com/windmill-labs/windmill/commit/e4e0984e55afd3c73f1c365cd0608493a9fd87ed))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** push whole raw app instead of treating frontend files as scripts ([#9442](https://github.com/windmill-labs/windmill/issues/9442)) ([b5a6a1e](https://github.com/windmill-labs/windmill/commit/b5a6a1eeab663c2d6aaec2c89eab7a550cb0bb6b))
|
||||
* read latest db draft for scripts/flows in global mode read tool ([#9441](https://github.com/windmill-labs/windmill/issues/9441)) ([819ba5e](https://github.com/windmill-labs/windmill/commit/819ba5e150ec9f5199919fbea50874fc156d0189))
|
||||
|
||||
## [1.716.0](https://github.com/windmill-labs/windmill/compare/v1.715.0...v1.716.0) (2026-06-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add metadata generation model setting ([#9418](https://github.com/windmill-labs/windmill/issues/9418)) ([cf5fefb](https://github.com/windmill-labs/windmill/commit/cf5fefb521479170b9dc64b884630c4dac789931))
|
||||
* auto-generate AI session names ([#9399](https://github.com/windmill-labs/windmill/issues/9399)) ([26b7270](https://github.com/windmill-labs/windmill/commit/26b727041830c9b741668a9ab73e2eb90c7cec74))
|
||||
* support $f/ and $u/ import path aliases for scripts ([#9378](https://github.com/windmill-labs/windmill/issues/9378)) ([220cd35](https://github.com/windmill-labs/windmill/commit/220cd35cf799c42ebf588bc97a6d8e6f4e97c2e3))
|
||||
* use metadata model for small AI tasks ([#9431](https://github.com/windmill-labs/windmill/issues/9431)) ([79178f6](https://github.com/windmill-labs/windmill/commit/79178f6f5a7c606a2e05677c6efcbdd84c608325))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **apps:** relock no longer reverts raw app to a stale version ([#9432](https://github.com/windmill-labs/windmill/issues/9432)) ([073857a](https://github.com/windmill-labs/windmill/commit/073857ac0a9ed54bdeac8f373f7c855fe34eb0ac))
|
||||
* **security:** scope variable and resource value caches by caller identity ([#9427](https://github.com/windmill-labs/windmill/issues/9427)) ([0ba128a](https://github.com/windmill-labs/windmill/commit/0ba128afe797bd016da60563949ac3abbbfe1978))
|
||||
|
||||
## [1.715.0](https://github.com/windmill-labs/windmill/compare/v1.714.1...v1.715.0) (2026-06-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add rebuild dependency map button to workspace settings ([#9424](https://github.com/windmill-labs/windmill/issues/9424)) ([3b2e748](https://github.com/windmill-labs/windmill/commit/3b2e748daf0a8ec4447c30423068df803f3f9ca2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** filter script/flow listings by token scope (GHSA-2ppx-66jv-wpw5) ([#9426](https://github.com/windmill-labs/windmill/issues/9426)) ([7edf3f0](https://github.com/windmill-labs/windmill/commit/7edf3f02122e20fde1e95e0252e7bda641075326))
|
||||
* **backend:** authorize single-job read endpoints by job/flow visibility ([#9416](https://github.com/windmill-labs/windmill/issues/9416)) ([89a7a37](https://github.com/windmill-labs/windmill/commit/89a7a377764086911db18252f2478f42f0e1e3ea))
|
||||
* **mcp:** resolve MCP resource token via caller RLS + SSRF-guard url ([#9428](https://github.com/windmill-labs/windmill/issues/9428)) ([8053266](https://github.com/windmill-labs/windmill/commit/8053266f88bd4c94fc86278412df5a0beeed5e77))
|
||||
* **nsjail:** precompile python stdlib + raise download rlimit_as ([#9429](https://github.com/windmill-labs/windmill/issues/9429)) ([7031744](https://github.com/windmill-labs/windmill/commit/7031744a199f0bf8b8e35043afa959977e5ecdbd))
|
||||
* omit temperature for gpt-5+ and o-series models on all providers ([#9422](https://github.com/windmill-labs/windmill/issues/9422)) ([11d1ad9](https://github.com/windmill-labs/windmill/commit/11d1ad9a872d2ec2f14cde35708c84a0c7bdc172))
|
||||
|
||||
## [1.714.1](https://github.com/windmill-labs/windmill/compare/v1.714.0...v1.714.1) (2026-06-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** route //native TypeScript previews to native workers (WIN-2007) ([#9407](https://github.com/windmill-labs/windmill/issues/9407)) ([73edebc](https://github.com/windmill-labs/windmill/commit/73edebc833a981488a8ea116f4f13c020a011a6f))
|
||||
* **nsjail:** raise python download fd limit for --compile-bytecode (WIN-2009) ([#9414](https://github.com/windmill-labs/windmill/issues/9414)) ([9e6559a](https://github.com/windmill-labs/windmill/commit/9e6559a6f688cc8d982277b19920219ea6d0fd8e))
|
||||
* **triggers:** prevent Zoom challenge handler from being used as a signing oracle ([#9413](https://github.com/windmill-labs/windmill/issues/9413)) ([ab2a15b](https://github.com/windmill-labs/windmill/commit/ab2a15b2a859096eabde718bf6e60289ae187118))
|
||||
|
||||
## [1.714.0](https://github.com/windmill-labs/windmill/compare/v1.713.1...v1.714.0) (2026-06-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add global ai chat test tools ([#9391](https://github.com/windmill-labs/windmill/issues/9391)) ([5c20d6b](https://github.com/windmill-labs/windmill/commit/5c20d6b4f79f2ccc1987ce7fdaf74e6b8f697846))
|
||||
* add workspace datatable tools to global AI chat mode ([#9395](https://github.com/windmill-labs/windmill/issues/9395)) ([943ef6e](https://github.com/windmill-labs/windmill/commit/943ef6eb2089f4b744cfa7945ce47f7f3b361ec7))
|
||||
* **flow-ai:** constrain flow-group colors to the NoteColor palette ([#9343](https://github.com/windmill-labs/windmill/issues/9343)) ([e4213c1](https://github.com/windmill-labs/windmill/commit/e4213c1ab8c448f492f372580f5c9df37e33fffc))
|
||||
* **frontend:** surface local drafts in drawer editors with an unsaved-changes banner ([#9335](https://github.com/windmill-labs/windmill/issues/9335)) ([075faab](https://github.com/windmill-labs/windmill/commit/075faabf3bba16a10a02ae3973008e5a13473085))
|
||||
* handle CTRL_BREAK_EVENT for graceful shutdown on Windows ([#9400](https://github.com/windmill-labs/windmill/issues/9400)) ([2e14456](https://github.com/windmill-labs/windmill/commit/2e1445616a412c5112ad2247b4087c7ddc218845))
|
||||
* refine ask-user-question chat display and keyboard nav ([#9392](https://github.com/windmill-labs/windmill/issues/9392)) ([1275487](https://github.com/windmill-labs/windmill/commit/1275487f028d4c74a9eeb18981ed05c225505be0))
|
||||
* sessions page with isolated AI chat + flow editor ([#9034](https://github.com/windmill-labs/windmill/issues/9034)) ([eadeac2](https://github.com/windmill-labs/windmill/commit/eadeac248bd022c2796cfe638eb617c6143b8fc4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change ([#9402](https://github.com/windmill-labs/windmill/issues/9402)) ([e356bb1](https://github.com/windmill-labs/windmill/commit/e356bb1f5df92eca3fbb0ca2114b9f4c32d4c496))
|
||||
* **cli:** stop git-sync promotion deploys from dropping triggers/schedules ([#9403](https://github.com/windmill-labs/windmill/issues/9403)) ([24e3ef2](https://github.com/windmill-labs/windmill/commit/24e3ef27be8498fb820c228a52febf6a0a91b487))
|
||||
* **frontend:** align Monaco editor font size with text-xs ([#9161](https://github.com/windmill-labs/windmill/issues/9161)) ([de76668](https://github.com/windmill-labs/windmill/commit/de76668c10c04abe8771a8ca7bba7b2259819a1c))
|
||||
* resolve username rename failing on apps with runnable deps ([#9401](https://github.com/windmill-labs/windmill/issues/9401)) ([e8ad53d](https://github.com/windmill-labs/windmill/commit/e8ad53dae92597f5a1a8b76f38a7d8c24f578a47))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **python:** add --compile-bytecode to uv pip install ([#9393](https://github.com/windmill-labs/windmill/issues/9393)) ([c19441b](https://github.com/windmill-labs/windmill/commit/c19441bc8cb2da064e4ad44d77dc04ab8bbb22ec))
|
||||
|
||||
## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01)
|
||||
|
||||
|
||||
|
||||
+18
-5
@@ -233,11 +233,14 @@ ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
ENV GO_PATH=/usr/local/go/bin/go
|
||||
|
||||
# Install UV
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY
|
||||
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
|
||||
# under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve
|
||||
# timestamps or Python's mtime-based .pyc invalidation discards these compiled files.
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode
|
||||
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode
|
||||
|
||||
|
||||
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -
|
||||
@@ -259,7 +262,7 @@ RUN export GOCACHE=/tmp/build_cache/go && \
|
||||
# chmod a+rw adds read+write WITHOUT removing execute bits (755->777, 644->666)
|
||||
# Note: uv python install only creates py_runtime, not uv cache - we create uv/go dirs for runtime
|
||||
RUN mkdir -p /tmp/windmill/cache && \
|
||||
cp -r /tmp/build_cache/* /tmp/windmill/cache/ && \
|
||||
cp -r --preserve=timestamps /tmp/build_cache/* /tmp/windmill/cache/ && \
|
||||
chmod -R a+rw /tmp/windmill/cache && \
|
||||
rm -rf /tmp/build_cache && \
|
||||
mkdir -p -m 777 /tmp/windmill/cache/uv /tmp/windmill/cache/go /tmp/windmill/cache/rustup /tmp/windmill/cache/cargo
|
||||
@@ -300,10 +303,20 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
|
||||
ENV LD_LIBRARY_PATH="."
|
||||
|
||||
# nsjail runtime deps and binary
|
||||
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox <image>`).
|
||||
# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md.
|
||||
ARG CRANE_VERSION=v0.20.6
|
||||
RUN arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \
|
||||
wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \
|
||||
&& tar -xzf /tmp/crane.tgz -C /usr/local/bin crane \
|
||||
&& rm /tmp/crane.tgz \
|
||||
&& chmod +x /usr/local/bin/crane
|
||||
|
||||
WORKDIR ${APP}
|
||||
|
||||
RUN ln -s ${APP}/windmill /usr/local/bin/windmill
|
||||
|
||||
@@ -86,6 +86,31 @@ Global prompts should exercise workspace-level drafting behavior:
|
||||
|
||||
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
|
||||
|
||||
Datatable cases should set `skipJudge: true` and validate through tool-use
|
||||
(`requiredToolsUsed` / `forbiddenToolsUsed`) and SQL-argument assertions
|
||||
(`toolCallArgs` with `stringIncludesAnyOf`, e.g. `['select']`, `['create table']`,
|
||||
`['update', 'insert into']`). Two reasons the judge is unreliable here:
|
||||
|
||||
- `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql`
|
||||
produce no drafts, and the global judge only sees the drafts artifact — it
|
||||
scores a no-draft conversational answer as empty (same as the
|
||||
`askUserQuestion` cases).
|
||||
- Even a case that *does* produce a draft (a script reading the data table via
|
||||
`wmill.datatable()` at runtime) is mis-judged: the judge has no datatable SDK
|
||||
reference and penalizes correct `wmill.datatable()` usage as wrong. Verify the
|
||||
SDK call deterministically instead — `requiredDrafts.valueIncludes: ['wmill.datatable(']`
|
||||
plus forbidding `exec_datatable_sql` (keeping chat-time SQL distinct from
|
||||
runtime SDK use).
|
||||
|
||||
`stringIncludesAnyOf` is existential over calls (at least one matching call), so a
|
||||
mutation case still passes when the model mixes its UPDATE/INSERT with
|
||||
verification SELECTs. The in-memory engine (`datatableSqlEngine.ts`) is stateful
|
||||
within a case — writes persist, so a model that re-queries to verify its
|
||||
CREATE/UPDATE sees the change and does not loop. But the engine is best-effort
|
||||
(SELECT returns all rows of the referenced/first table with no WHERE/projection),
|
||||
so still never assert specific returned row values. Seed data via
|
||||
`workspace.datatables` in the `initial` fixture (see README).
|
||||
|
||||
## Deterministic validation
|
||||
|
||||
Use deterministic validation only for hard failures such as:
|
||||
|
||||
@@ -148,6 +148,23 @@ Global initial fixtures can also seed `liveEditorDrafts` with `type`,
|
||||
currently open script, flow, or raw app editor so cases can test prompts that
|
||||
refer to "this" or the "current" item.
|
||||
|
||||
Global (and flow) initial fixtures can seed `workspace.datatables` so the
|
||||
`list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools
|
||||
return seeded data during evals. Each entry is
|
||||
`{ datatable_name, schemas: { <schema>: { <table>: { columns, rows? } } } }`.
|
||||
SQL runs through a small in-memory engine (`datatableSqlEngine.ts`), not a real
|
||||
database. Writes are **stateful within a case**: `CREATE`/`DROP`/`INSERT`/`UPDATE`/
|
||||
`DELETE` mutate the seeded datatable in place, so a later `list_datatables`,
|
||||
`get_datatable_table_schema`, `SELECT`, or `information_schema` query reflects them
|
||||
— this is what stops a model from looping when it re-queries to verify a write.
|
||||
The engine is best-effort: `SELECT` returns all rows of the referenced (or first)
|
||||
table with no WHERE filtering/projection/joins, `WHERE` on UPDATE/DELETE supports
|
||||
`col = value` predicates joined by `AND`, and anything unparseable is a no-op
|
||||
success. So validate datatable cases through tool-use and SQL-argument assertions
|
||||
(`requiredToolsUsed`, `stringIncludesAnyOf`) — not through exact returned row
|
||||
values. An empty/absent `datatables` seed makes `list_datatables` return `[]`,
|
||||
which is what the "no datatable configured" blocking cases rely on.
|
||||
|
||||
Set `WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT=1` to run those cases with
|
||||
the old behavior where the live editor is only discoverable through
|
||||
`list_workspace_items`.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine'
|
||||
|
||||
function makeDatatable(): BenchmarkDatatableSeed {
|
||||
return {
|
||||
datatable_name: 'main',
|
||||
schemas: {
|
||||
public: {
|
||||
orders: {
|
||||
columns: { id: 'int4', customer_id: 'int4', total: 'numeric', status: 'text' },
|
||||
rows: [
|
||||
{ id: 1, customer_id: 1, total: 42.5, status: 'shipped' },
|
||||
{ id: 2, customer_id: 2, total: 19.99, status: 'pending' },
|
||||
{ id: 3, customer_id: 1, total: 88, status: 'shipped' }
|
||||
]
|
||||
},
|
||||
customers: {
|
||||
columns: { id: 'int4', name: 'text' },
|
||||
rows: [{ id: 1, name: 'Alice' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('SELECT', () => {
|
||||
it('returns the referenced table rows', () => {
|
||||
const dt = makeDatatable()
|
||||
expect(applyDatatableSql(dt, 'SELECT id, name FROM customers').rows).toEqual([
|
||||
{ id: 1, name: 'Alice' }
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the first table when no known table is referenced', () => {
|
||||
const dt = makeDatatable()
|
||||
expect(applyDatatableSql(dt, 'select 1').rows).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('resolves a schema-qualified table', () => {
|
||||
const dt = makeDatatable()
|
||||
expect(applyDatatableSql(dt, 'SELECT * FROM public.customers').rows).toEqual([
|
||||
{ id: 1, name: 'Alice' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CREATE TABLE', () => {
|
||||
it('adds a table with parsed columns, skipping table constraints and FK clauses', () => {
|
||||
const dt = makeDatatable()
|
||||
const result = applyDatatableSql(
|
||||
dt,
|
||||
'CREATE TABLE public.refunds (\n order_id int4 NOT NULL REFERENCES public.orders(id),\n amount numeric(10,2),\n PRIMARY KEY (order_id)\n)'
|
||||
)
|
||||
expect(result.rows).toEqual([])
|
||||
expect(dt.schemas.public.refunds).toEqual({
|
||||
columns: { order_id: 'int4', amount: 'numeric(10,2)' },
|
||||
rows: []
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults an unqualified table to the public schema', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'CREATE TABLE notes (id int4, body text)')
|
||||
expect(dt.schemas.public.notes.columns).toEqual({ id: 'int4', body: 'text' })
|
||||
})
|
||||
|
||||
it('is a no-op for an existing table with IF NOT EXISTS', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'CREATE TABLE IF NOT EXISTS public.orders (x int4)')
|
||||
expect(Object.keys(dt.schemas.public.orders.columns)).toContain('status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DROP TABLE', () => {
|
||||
it('removes the table', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'DROP TABLE IF EXISTS public.customers')
|
||||
expect(dt.schemas.public.customers).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('INSERT', () => {
|
||||
it('appends a row using an explicit column list', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (2, 'Bob')")
|
||||
expect(dt.schemas.public.customers.rows).toContainEqual({ id: 2, name: 'Bob' })
|
||||
})
|
||||
|
||||
it('infers columns from the table when none are given, and appends multiple tuples', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "INSERT INTO customers VALUES (2, 'Bob'), (3, 'Carol')")
|
||||
expect(dt.schemas.public.customers.rows).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns the inserted rows when RETURNING is present', () => {
|
||||
const dt = makeDatatable()
|
||||
const result = applyDatatableSql(
|
||||
dt,
|
||||
"INSERT INTO customers (id, name) VALUES (2, 'Bob') RETURNING *"
|
||||
)
|
||||
expect(result.rows).toEqual([{ id: 2, name: 'Bob' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('UPDATE', () => {
|
||||
it('updates only the rows matching an equality WHERE', () => {
|
||||
const dt = makeDatatable()
|
||||
const result = applyDatatableSql(
|
||||
dt,
|
||||
"UPDATE public.orders SET status = 'shipped' WHERE id = 2"
|
||||
)
|
||||
expect(result.rows).toEqual([])
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('shipped')
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped')
|
||||
})
|
||||
|
||||
it('strips a Postgres cast in the WHERE value', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "UPDATE orders SET status = 'done' WHERE id = 2::int4")
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('done')
|
||||
})
|
||||
|
||||
it('matches multiple AND predicates including a numeric literal', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(
|
||||
dt,
|
||||
"UPDATE orders SET status = 'done' WHERE customer_id = 2 AND total = 19.99"
|
||||
)
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('done')
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped')
|
||||
})
|
||||
|
||||
it('updates every row when there is no WHERE', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "UPDATE orders SET status = 'archived'")
|
||||
expect(dt.schemas.public.orders.rows?.every((r) => r.status === 'archived')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the affected rows when RETURNING is present', () => {
|
||||
const dt = makeDatatable()
|
||||
const result = applyDatatableSql(
|
||||
dt,
|
||||
"UPDATE orders SET status = 'shipped' WHERE id = 2 RETURNING *"
|
||||
)
|
||||
expect(result.rows).toHaveLength(1)
|
||||
expect(result.rows[0]).toMatchObject({ id: 2, status: 'shipped' })
|
||||
})
|
||||
|
||||
it('affects no rows when the WHERE clause cannot be parsed', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "UPDATE orders SET status = 'x' WHERE total > 20")
|
||||
expect(dt.schemas.public.orders.rows?.some((r) => r.status === 'x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('removes only the matching rows', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'DELETE FROM orders WHERE id = 2')
|
||||
expect(dt.schemas.public.orders.rows?.map((r) => r.id)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('returns the removed rows when RETURNING is present', () => {
|
||||
const dt = makeDatatable()
|
||||
const result = applyDatatableSql(dt, 'DELETE FROM orders WHERE id = 2 RETURNING *')
|
||||
expect(result.rows).toEqual([{ id: 2, customer_id: 2, total: 19.99, status: 'pending' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('writes are reflected by later reads', () => {
|
||||
it('UPDATE then SELECT sees the new value (the verify-loop fix)', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "UPDATE orders SET status = 'shipped' WHERE id = 2")
|
||||
const seen = applyDatatableSql(dt, 'SELECT * FROM orders').rows
|
||||
expect(seen.find((r) => r.id === 2)?.status).toBe('shipped')
|
||||
})
|
||||
|
||||
it('INSERT then SELECT sees the new row', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (9, 'Zed')")
|
||||
const seen = applyDatatableSql(dt, 'SELECT * FROM customers').rows
|
||||
expect(seen).toContainEqual({ id: 9, name: 'Zed' })
|
||||
})
|
||||
|
||||
it('CREATE then SELECT on the new table returns its (empty) rows', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'CREATE TABLE public.refunds (order_id int4, amount numeric)')
|
||||
expect(applyDatatableSql(dt, 'SELECT * FROM refunds').rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('system-catalog queries reflect the current tables/columns', () => {
|
||||
it('lists current tables (including a freshly created one) via information_schema.tables', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'CREATE TABLE public.refunds (order_id int4)')
|
||||
const rows = applyDatatableSql(
|
||||
dt,
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refunds'"
|
||||
).rows
|
||||
expect(rows.map((r) => r.table_name)).toContain('refunds')
|
||||
})
|
||||
|
||||
it('does not list a dropped table', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, 'DROP TABLE public.customers')
|
||||
const rows = applyDatatableSql(dt, 'SELECT table_name FROM information_schema.tables').rows
|
||||
expect(rows.map((r) => r.table_name)).not.toContain('customers')
|
||||
})
|
||||
|
||||
it('reports columns via information_schema.columns', () => {
|
||||
const dt = makeDatatable()
|
||||
const rows = applyDatatableSql(
|
||||
dt,
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'orders'"
|
||||
).rows
|
||||
expect(rows.map((r) => r.column_name)).toContain('status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parser robustness (string/paren-aware splitting)', () => {
|
||||
it('does not treat the word "returning" inside a string value as a RETURNING clause', () => {
|
||||
const dt = makeDatatable()
|
||||
const result = applyDatatableSql(
|
||||
dt,
|
||||
"INSERT INTO customers (id, name) VALUES (5, 'is returning soon')"
|
||||
)
|
||||
expect(result.rows).toEqual([])
|
||||
expect(dt.schemas.public.customers.rows).toContainEqual({ id: 5, name: 'is returning soon' })
|
||||
})
|
||||
|
||||
it('does not split on the word "where" inside a SET string value', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "UPDATE orders SET status = 'ship where ordered' WHERE id = 2")
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('ship where ordered')
|
||||
expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped')
|
||||
})
|
||||
|
||||
it('keeps INSERT tuples intact when a value contains a function call', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (6, coalesce(NULL, 'x'))")
|
||||
expect(dt.schemas.public.customers.rows).toHaveLength(2)
|
||||
expect(dt.schemas.public.customers.rows?.[1]).toMatchObject({ id: 6 })
|
||||
})
|
||||
|
||||
it('CREATE TABLE ignores a trailing semicolon-separated statement', () => {
|
||||
const dt = makeDatatable()
|
||||
applyDatatableSql(
|
||||
dt,
|
||||
'CREATE TABLE public.refunds (id int4, amount numeric); INSERT INTO refunds VALUES (1, 5)'
|
||||
)
|
||||
expect(dt.schemas.public.refunds.columns).toEqual({ id: 'int4', amount: 'numeric' })
|
||||
expect(dt.schemas.public.refunds.rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unparseable statements are a safe no-op', () => {
|
||||
it('returns [] and does not throw', () => {
|
||||
const dt = makeDatatable()
|
||||
expect(applyDatatableSql(dt, 'VACUUM ANALYZE').rows).toEqual([])
|
||||
expect(applyDatatableSql(dt, 'GRANT SELECT ON orders TO someone').rows).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* A deliberately small, best-effort SQL engine for the benchmark datatable mock.
|
||||
*
|
||||
* This is NOT a real SQL implementation — it exists only so that writes a model
|
||||
* issues during an eval (`CREATE TABLE`, `INSERT`, `UPDATE`, `DELETE`, `DROP`)
|
||||
* become visible to its later reads (`list_datatables`, `get_datatable_table_schema`,
|
||||
* `SELECT`). Without that, a model that re-queries to verify a write sees stale
|
||||
* seed data, concludes the write failed, and loops until it exhausts its turns.
|
||||
*
|
||||
* It parses only the common statement shapes models produce. Anything it cannot
|
||||
* parse is a no-op success (it never throws) — behavioral evals assert that the
|
||||
* right statement was issued, not its exact data effects. Notable limits:
|
||||
* - `SELECT` returns all rows of the referenced (or first) table — no WHERE
|
||||
* filtering, projection, joins, or aggregation.
|
||||
* - `WHERE` supports `col = value` predicates joined by `AND` only; an
|
||||
* unparseable WHERE on UPDATE/DELETE affects zero rows (never the whole table).
|
||||
*/
|
||||
|
||||
/** One seeded datatable table: its columns (col -> compact_type) and optional rows. */
|
||||
export interface BenchmarkDatatableTableSeed {
|
||||
columns: Record<string, string>
|
||||
rows?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/** A seeded datatable: `datatable_name` plus a `schema -> table -> seed` map. */
|
||||
export interface BenchmarkDatatableSeed {
|
||||
datatable_name: string
|
||||
schemas: {
|
||||
[schema: string]: {
|
||||
[table: string]: BenchmarkDatatableTableSeed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DatatableSqlResult {
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const DEFAULT_SCHEMA = 'public'
|
||||
|
||||
type ParsedRef = { schema: string; table: string }
|
||||
type Predicate = { column: string; value: unknown }
|
||||
|
||||
/**
|
||||
* Apply one SQL statement to `datatable` IN PLACE and return the result rows.
|
||||
* SELECT returns the referenced/first table's rows; a mutation returns its
|
||||
* affected rows when it has a RETURNING clause, otherwise `[]`.
|
||||
*/
|
||||
export function applyDatatableSql(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): DatatableSqlResult {
|
||||
const statement = stripTrailingSemicolon(sql.trim())
|
||||
if (/^\s*(with|select)\b/i.test(statement)) {
|
||||
return { rows: selectRows(datatable, statement) }
|
||||
}
|
||||
if (/^\s*create\s+table\b/i.test(statement)) {
|
||||
return { rows: applyCreateTable(datatable, statement) }
|
||||
}
|
||||
if (/^\s*drop\s+table\b/i.test(statement)) {
|
||||
return { rows: applyDropTable(datatable, statement) }
|
||||
}
|
||||
if (/^\s*insert\s+into\b/i.test(statement)) {
|
||||
return { rows: applyInsert(datatable, statement) }
|
||||
}
|
||||
if (/^\s*update\b/i.test(statement)) {
|
||||
return { rows: applyUpdate(datatable, statement) }
|
||||
}
|
||||
if (/^\s*delete\s+from\b/i.test(statement)) {
|
||||
return { rows: applyDelete(datatable, statement) }
|
||||
}
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
// ============= Reads =============
|
||||
|
||||
function selectRows(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): Record<string, unknown>[] {
|
||||
const fromRef = sql.match(/\bfrom\s+([a-zA-Z_"][\w."]*)/i)?.[1]
|
||||
if (fromRef) {
|
||||
const catalog = catalogRows(datatable, fromRef)
|
||||
if (catalog) {
|
||||
return catalog
|
||||
}
|
||||
}
|
||||
const table = fromRef ? resolveTable(datatable, fromRef) : undefined
|
||||
const seed = table ?? firstTable(datatable)
|
||||
return seed?.rows ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize rows for a system-catalog query so a model verifying a `CREATE`/`DROP`
|
||||
* via `information_schema.tables` / `.columns` (or `pg_tables`) sees the current
|
||||
* tables/columns instead of fallback data. WHERE is not applied, so the model gets
|
||||
* the full set and finds (or no longer finds) the table it just changed.
|
||||
* Returns `undefined` for non-catalog refs so normal table resolution proceeds.
|
||||
*/
|
||||
function catalogRows(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
ref: string
|
||||
): Record<string, unknown>[] | undefined {
|
||||
const normalized = ref.toLowerCase().replace(/"/g, '')
|
||||
const name = normalized.split('.').pop()
|
||||
const isCatalog = normalized.includes('information_schema.') || normalized.startsWith('pg_')
|
||||
if (!isCatalog) {
|
||||
return undefined
|
||||
}
|
||||
const tables = allTables(datatable)
|
||||
if (name === 'tables' || name === 'pg_tables') {
|
||||
return tables.map(({ schema, table }) => ({
|
||||
table_schema: schema,
|
||||
table_name: table,
|
||||
schemaname: schema,
|
||||
tablename: table
|
||||
}))
|
||||
}
|
||||
if (name === 'columns') {
|
||||
return tables.flatMap(({ schema, table, seed }) =>
|
||||
Object.entries(seed.columns).map(([column, type]) => ({
|
||||
table_schema: schema,
|
||||
table_name: table,
|
||||
column_name: column,
|
||||
data_type: type
|
||||
}))
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function allTables(
|
||||
datatable: BenchmarkDatatableSeed
|
||||
): { schema: string; table: string; seed: BenchmarkDatatableTableSeed }[] {
|
||||
return Object.entries(datatable.schemas).flatMap(([schema, tables]) =>
|
||||
Object.entries(tables).map(([table, seed]) => ({ schema, table, seed }))
|
||||
)
|
||||
}
|
||||
|
||||
// ============= DDL =============
|
||||
|
||||
function applyCreateTable(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): Record<string, unknown>[] {
|
||||
const head = sql.match(
|
||||
/^\s*create\s+table\s+(?:if\s+not\s+exists\s+)?([a-zA-Z_"][\w."]*)/i
|
||||
)
|
||||
// The first top-level paren group is the column-definition list; using it (rather
|
||||
// than a greedy `(...)` capture) ignores any trailing `;`-separated statement.
|
||||
const columnText = extractParenGroups(sql)[0]
|
||||
if (!head || columnText === undefined) {
|
||||
return []
|
||||
}
|
||||
const { schema, table } = parseRef(head[1])
|
||||
const existing = datatable.schemas[schema]?.[table]
|
||||
if (existing) {
|
||||
return []
|
||||
}
|
||||
const columns: Record<string, string> = {}
|
||||
for (const rawDef of splitTopLevel(columnText)) {
|
||||
const def = rawDef.trim()
|
||||
if (!def || isTableConstraint(def)) {
|
||||
continue
|
||||
}
|
||||
const tokens = def.split(/\s+/)
|
||||
const column = unquoteIdentifier(tokens[0])
|
||||
if (!column) {
|
||||
continue
|
||||
}
|
||||
columns[column] = tokens[1] ?? 'text'
|
||||
}
|
||||
if (!datatable.schemas[schema]) {
|
||||
datatable.schemas[schema] = {}
|
||||
}
|
||||
datatable.schemas[schema][table] = { columns, rows: [] }
|
||||
return []
|
||||
}
|
||||
|
||||
function applyDropTable(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): Record<string, unknown>[] {
|
||||
const match = sql.match(
|
||||
/^\s*drop\s+table\s+(?:if\s+exists\s+)?([a-zA-Z_"][\w."]*)/i
|
||||
)
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
const { schema, table } = parseRef(match[1])
|
||||
if (datatable.schemas[schema]?.[table]) {
|
||||
delete datatable.schemas[schema][table]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// ============= DML =============
|
||||
|
||||
function applyInsert(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): Record<string, unknown>[] {
|
||||
const { body, returning } = splitOffReturning(sql)
|
||||
const match = body.match(
|
||||
/^\s*insert\s+into\s+([a-zA-Z_"][\w."]*)\s*(?:\(([^)]*)\))?\s*values\s*([\s\S]+)$/i
|
||||
)
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
const table = resolveTable(datatable, match[1])
|
||||
if (!table) {
|
||||
return []
|
||||
}
|
||||
const columns = match[2]
|
||||
? splitTopLevel(match[2]).map((entry) => unquoteIdentifier(entry.trim()))
|
||||
: Object.keys(table.columns)
|
||||
const inserted: Record<string, unknown>[] = []
|
||||
for (const tuple of extractParenGroups(match[3])) {
|
||||
const values = splitTopLevel(tuple).map((entry) => parseValue(entry))
|
||||
const row: Record<string, unknown> = {}
|
||||
columns.forEach((column, index) => {
|
||||
row[column] = values[index]
|
||||
})
|
||||
inserted.push(row)
|
||||
}
|
||||
table.rows ??= []
|
||||
table.rows.push(...inserted)
|
||||
return returning ? inserted : []
|
||||
}
|
||||
|
||||
function applyUpdate(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): Record<string, unknown>[] {
|
||||
const { body, returning } = splitOffReturning(sql)
|
||||
const match = body.match(/^\s*update\s+([a-zA-Z_"][\w."]*)\s+set\s+([\s\S]+)$/i)
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
const table = resolveTable(datatable, match[1])
|
||||
if (!table) {
|
||||
return []
|
||||
}
|
||||
let assignmentText = match[2]
|
||||
let whereText: string | undefined
|
||||
const whereMatch = maskForClauseScan(assignmentText).match(/\swhere\s/i)
|
||||
if (whereMatch && whereMatch.index !== undefined) {
|
||||
whereText = assignmentText.slice(whereMatch.index + whereMatch[0].length)
|
||||
assignmentText = assignmentText.slice(0, whereMatch.index)
|
||||
}
|
||||
const predicates = parsePredicates(whereText)
|
||||
if (predicates === null) {
|
||||
return []
|
||||
}
|
||||
const assignments: Record<string, unknown> = {}
|
||||
for (const entry of splitTopLevel(assignmentText)) {
|
||||
const pair = entry.match(/^\s*([a-zA-Z_"][\w."]*)\s*=\s*([\s\S]+?)\s*$/)
|
||||
if (pair) {
|
||||
assignments[lastIdentifier(pair[1])] = parseValue(pair[2])
|
||||
}
|
||||
}
|
||||
const affected = (table.rows ?? []).filter((row) => rowMatches(row, predicates))
|
||||
for (const row of affected) {
|
||||
Object.assign(row, assignments)
|
||||
}
|
||||
return returning ? affected : []
|
||||
}
|
||||
|
||||
function applyDelete(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
sql: string
|
||||
): Record<string, unknown>[] {
|
||||
const { body, returning } = splitOffReturning(sql)
|
||||
const match = body.match(/^\s*delete\s+from\s+([a-zA-Z_"][\w."]*)\s*([\s\S]*)$/i)
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
const table = resolveTable(datatable, match[1])
|
||||
if (!table) {
|
||||
return []
|
||||
}
|
||||
const whereText = match[2].replace(/^\s*where\s+/i, '').trim() || undefined
|
||||
const predicates = parsePredicates(whereText)
|
||||
if (predicates === null) {
|
||||
return []
|
||||
}
|
||||
const rows = table.rows ?? []
|
||||
const removed = rows.filter((row) => rowMatches(row, predicates))
|
||||
table.rows = rows.filter((row) => !rowMatches(row, predicates))
|
||||
return returning ? removed : []
|
||||
}
|
||||
|
||||
// ============= Parsing helpers =============
|
||||
|
||||
function resolveTable(
|
||||
datatable: BenchmarkDatatableSeed,
|
||||
ref: string
|
||||
): BenchmarkDatatableTableSeed | undefined {
|
||||
const { schema, table } = parseRef(ref)
|
||||
const direct = datatable.schemas[schema]?.[table]
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
// Bare table name: fall back to searching every schema for a matching table.
|
||||
if (!ref.includes('.')) {
|
||||
for (const tables of Object.values(datatable.schemas)) {
|
||||
if (tables[table]) {
|
||||
return tables[table]
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function firstTable(
|
||||
datatable: BenchmarkDatatableSeed
|
||||
): BenchmarkDatatableTableSeed | undefined {
|
||||
for (const tables of Object.values(datatable.schemas)) {
|
||||
for (const seed of Object.values(tables)) {
|
||||
return seed
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseRef(ref: string): ParsedRef {
|
||||
const parts = ref.split('.').map(unquoteIdentifier)
|
||||
if (parts.length >= 2) {
|
||||
return { schema: parts[parts.length - 2], table: parts[parts.length - 1] }
|
||||
}
|
||||
return { schema: DEFAULT_SCHEMA, table: parts[0] }
|
||||
}
|
||||
|
||||
/** A WHERE clause with no parseable form returns `null`; absent WHERE returns `[]` (match all). */
|
||||
function parsePredicates(whereText: string | undefined): Predicate[] | null {
|
||||
if (whereText === undefined || whereText.trim() === '') {
|
||||
return []
|
||||
}
|
||||
const predicates: Predicate[] = []
|
||||
for (const part of whereText.split(/\s+and\s+/i)) {
|
||||
const match = part.match(/^\s*([a-zA-Z_"][\w."]*)\s*=\s*([\s\S]+?)\s*$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
predicates.push({ column: lastIdentifier(match[1]), value: parseValue(match[2]) })
|
||||
}
|
||||
return predicates
|
||||
}
|
||||
|
||||
function rowMatches(row: Record<string, unknown>, predicates: Predicate[]): boolean {
|
||||
return predicates.every((predicate) => looseEquals(row[predicate.column], predicate.value))
|
||||
}
|
||||
|
||||
function looseEquals(left: unknown, right: unknown): boolean {
|
||||
if (left === null || left === undefined) {
|
||||
return right === null || right === undefined
|
||||
}
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return left === right
|
||||
}
|
||||
return String(left) === String(right)
|
||||
}
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
// Drop a trailing Postgres cast (e.g. `2::int4`) before interpreting the literal.
|
||||
const token = raw.trim().replace(/::\s*[a-zA-Z_][\w]*(\([^)]*\))?\s*$/, '').trim()
|
||||
const stringMatch = token.match(/^'([\s\S]*)'$/)
|
||||
if (stringMatch) {
|
||||
return stringMatch[1].replace(/''/g, "'")
|
||||
}
|
||||
if (/^-?\d+(\.\d+)?$/.test(token)) {
|
||||
return Number(token)
|
||||
}
|
||||
if (/^true$/i.test(token)) {
|
||||
return true
|
||||
}
|
||||
if (/^false$/i.test(token)) {
|
||||
return false
|
||||
}
|
||||
if (/^null$/i.test(token)) {
|
||||
return null
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
function splitOffReturning(sql: string): { body: string; returning: boolean } {
|
||||
const match = maskForClauseScan(sql).match(/\sreturning\s/i)
|
||||
if (!match || match.index === undefined) {
|
||||
return { body: sql, returning: false }
|
||||
}
|
||||
return { body: sql.slice(0, match.index), returning: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* A same-length copy of `sql` with the contents of single-quoted strings and
|
||||
* parenthesized groups blanked to spaces, so a top-level keyword scan
|
||||
* (WHERE / RETURNING) cannot match inside a string literal or a subquery. Index
|
||||
* positions in the result map 1:1 back onto the original.
|
||||
*/
|
||||
function maskForClauseScan(sql: string): string {
|
||||
let masked = ''
|
||||
let depth = 0
|
||||
let inString = false
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i]
|
||||
if (inString) {
|
||||
if (char === "'") {
|
||||
if (sql[i + 1] === "'") {
|
||||
masked += ' '
|
||||
i++
|
||||
continue
|
||||
}
|
||||
inString = false
|
||||
}
|
||||
masked += ' '
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
inString = true
|
||||
masked += ' '
|
||||
} else if (char === '(') {
|
||||
depth++
|
||||
masked += ' '
|
||||
} else if (char === ')') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
masked += ' '
|
||||
} else {
|
||||
masked += depth > 0 ? ' ' : char
|
||||
}
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner text of each top-level `( ... )` group in `input`, honoring nested parens
|
||||
* (e.g. `now()`, `numeric(10,2)`) and single-quoted strings. Used for the CREATE
|
||||
* column-definition group and INSERT value tuples.
|
||||
*/
|
||||
function extractParenGroups(input: string): string[] {
|
||||
const groups: string[] = []
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let current = ''
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i]
|
||||
if (inString) {
|
||||
current += char
|
||||
if (char === "'") {
|
||||
if (input[i + 1] === "'") {
|
||||
current += input[++i]
|
||||
} else {
|
||||
inString = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
inString = true
|
||||
current += char
|
||||
} else if (char === '(') {
|
||||
depth++
|
||||
if (depth === 1) {
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
} else if (char === ')') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
if (depth === 0) {
|
||||
groups.push(current)
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
} else if (depth > 0) {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** Split on commas that are not inside parentheses or single-quoted strings. */
|
||||
function splitTopLevel(input: string): string[] {
|
||||
const parts: string[] = []
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let current = ''
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i]
|
||||
if (inString) {
|
||||
current += char
|
||||
if (char === "'") {
|
||||
if (input[i + 1] === "'") {
|
||||
current += input[++i]
|
||||
} else {
|
||||
inString = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
inString = true
|
||||
current += char
|
||||
} else if (char === '(') {
|
||||
depth++
|
||||
current += char
|
||||
} else if (char === ')') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
current += char
|
||||
} else if (char === ',' && depth === 0) {
|
||||
parts.push(current)
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
if (current.trim() !== '') {
|
||||
parts.push(current)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function isTableConstraint(def: string): boolean {
|
||||
return /^(primary\s+key|foreign\s+key|constraint|unique|check|exclude|like)\b/i.test(def)
|
||||
}
|
||||
|
||||
function unquoteIdentifier(identifier: string): string {
|
||||
const trimmed = identifier.trim()
|
||||
const quoted = trimmed.match(/^"([\s\S]*)"$/)
|
||||
return quoted ? quoted[1] : trimmed
|
||||
}
|
||||
|
||||
/** For a qualified reference like `orders.id`, keep only the final identifier. */
|
||||
function lastIdentifier(reference: string): string {
|
||||
const parts = reference.split('.')
|
||||
return unquoteIdentifier(parts[parts.length - 1])
|
||||
}
|
||||
|
||||
function stripTrailingSemicolon(sql: string): string {
|
||||
return sql.replace(/;\s*$/, '')
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CompletedJob, Flow, Script } from '../../../frontend/src/lib/gen'
|
||||
import type { ScriptLang } from '../../../frontend/src/lib/gen/types.gen'
|
||||
import type {
|
||||
DataTableTables,
|
||||
DataTableTableSchema,
|
||||
ScriptLang
|
||||
} from '../../../frontend/src/lib/gen/types.gen'
|
||||
import { buildScriptLintResult } from './core/script/preview'
|
||||
import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine'
|
||||
|
||||
export type { BenchmarkDatatableSeed, BenchmarkDatatableTableSeed } from './datatableSqlEngine'
|
||||
|
||||
const BENCHMARK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
||||
|
||||
@@ -25,6 +32,7 @@ export interface BenchmarkWorkspaceFlow {
|
||||
export interface BenchmarkWorkspaceRunnables {
|
||||
scripts?: BenchmarkWorkspaceScript[]
|
||||
flows?: BenchmarkWorkspaceFlow[]
|
||||
datatables?: BenchmarkDatatableSeed[]
|
||||
}
|
||||
|
||||
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
|
||||
@@ -48,7 +56,12 @@ export function registerBenchmarkWorkspaceRunnables(
|
||||
runnables: BenchmarkWorkspaceRunnables
|
||||
): void {
|
||||
benchmarkWorkspaces.add(workspace)
|
||||
benchmarkWorkspaceRunnables.set(workspace, runnables)
|
||||
// Datatables are mutated in place by exec_datatable_sql (a write must be visible
|
||||
// to later reads), so store an isolated deep copy — never mutate the caller's seed.
|
||||
benchmarkWorkspaceRunnables.set(workspace, {
|
||||
...runnables,
|
||||
datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function unregisterBenchmarkWorkspace(workspace: string): void {
|
||||
@@ -161,6 +174,99 @@ export function getBenchmarkCompletedJob(
|
||||
return structuredClone(entry.job)
|
||||
}
|
||||
|
||||
// ============= Datatables (best-effort in-memory SQL) =============
|
||||
|
||||
/**
|
||||
* Project the seeded datatables down to the `list_datatable_tables` response:
|
||||
* `datatable_name` + `schema -> table_names`, with no column detail.
|
||||
* Returns `null` for a non-benchmark workspace so callers can fall through to
|
||||
* the real backend; an empty seed yields `[]`.
|
||||
*/
|
||||
export function listBenchmarkDatatables(workspace: string): DataTableTables[] | null {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(workspace)
|
||||
if (!runnables) {
|
||||
return null
|
||||
}
|
||||
return (runnables.datatables ?? []).map((datatable) => ({
|
||||
datatable_name: datatable.datatable_name,
|
||||
schemas: Object.fromEntries(
|
||||
Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)])
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
export function getBenchmarkDatatableSchema(input: {
|
||||
workspace: string
|
||||
datatableName: string
|
||||
schemaName: string
|
||||
tableName: string
|
||||
}): DataTableTableSchema {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(input.workspace)
|
||||
const datatable = (runnables?.datatables ?? []).find(
|
||||
(entry) => entry.datatable_name === input.datatableName
|
||||
)
|
||||
if (!datatable) {
|
||||
// Message MUST match the production `isDatatableNotConfiguredError` regex
|
||||
// (/datatable\s+\S+\s+not found/i in datatableTools.ts) so the
|
||||
// get_datatable_table_schema not-configured mapping is actually exercised.
|
||||
throw new Error(`datatable "${input.datatableName}" not found`)
|
||||
}
|
||||
const table = datatable.schemas?.[input.schemaName]?.[input.tableName]
|
||||
if (!table) {
|
||||
throw new Error(
|
||||
`table "${input.schemaName}.${input.tableName}" not found in datatable "${input.datatableName}"`
|
||||
)
|
||||
}
|
||||
return {
|
||||
datatable_name: input.datatableName,
|
||||
schema_name: input.schemaName,
|
||||
table_name: input.tableName,
|
||||
columns: table.columns
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute SQL against a seeded datatable through the best-effort in-memory engine
|
||||
* (`applyDatatableSql`). Writes (CREATE/INSERT/UPDATE/DELETE/DROP) mutate the
|
||||
* stored datatable in place so a later list/schema/SELECT reflects them; SELECT
|
||||
* (and RETURNING) yield rows, other statements yield `[]`. Creates a benchmark
|
||||
* completed job and returns its id, like `runBenchmarkScriptPreview`.
|
||||
*/
|
||||
export function runBenchmarkDatatableSql(input: {
|
||||
workspace: string
|
||||
datatableName: string
|
||||
sql: string
|
||||
}): string {
|
||||
const runnables = benchmarkWorkspaceRunnables.get(input.workspace)
|
||||
const datatable = (runnables?.datatables ?? []).find(
|
||||
(entry) => entry.datatable_name === input.datatableName
|
||||
)
|
||||
const rows = datatable ? applyDatatableSql(datatable, input.sql).rows : []
|
||||
return createBenchmarkCompletedJob({
|
||||
workspace: input.workspace,
|
||||
jobKind: 'preview',
|
||||
success: true,
|
||||
args: { database: `datatable://${input.datatableName}` },
|
||||
result: rows
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror `JobService.getCompletedJobResultMaybe` for benchmark workspaces — the
|
||||
* shape `pollJobResult` consumes. The job is created synchronously before
|
||||
* polling, so it is always present and completed.
|
||||
*/
|
||||
export function getBenchmarkCompletedJobResultMaybe(input: {
|
||||
workspace: string
|
||||
id: string
|
||||
}): { success: boolean; completed: boolean; result: unknown } {
|
||||
const job = getBenchmarkCompletedJob(input.workspace, input.id)
|
||||
if (!job) {
|
||||
throw new Error(`Job "${input.id}" not found in benchmark workspace`)
|
||||
}
|
||||
return { success: job.success, completed: true, result: job.result }
|
||||
}
|
||||
|
||||
export function runBenchmarkScriptPreview(input: {
|
||||
workspace: string
|
||||
requestBody: {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
getBenchmarkCompletedJobResultMaybe,
|
||||
getBenchmarkDatatableSchema,
|
||||
listBenchmarkDatatables,
|
||||
registerBenchmarkWorkspaceRunnables,
|
||||
resetBenchmarkMockBackend,
|
||||
runBenchmarkDatatableSql,
|
||||
type BenchmarkWorkspaceRunnables
|
||||
} from './mockBackend'
|
||||
|
||||
const WORKSPACE = 'benchmark-datatable-ws'
|
||||
|
||||
// Mirrors the production `isDatatableNotConfiguredError` regex in
|
||||
// datatableTools.ts. The schema mock's "not configured" message MUST match it,
|
||||
// otherwise the not-configured mapping in get_datatable_table_schema is silently
|
||||
// untested.
|
||||
const NOT_CONFIGURED_RE = /datatable\s+\S+\s+not found/i
|
||||
|
||||
const SEED: BenchmarkWorkspaceRunnables = {
|
||||
datatables: [
|
||||
{
|
||||
datatable_name: 'main',
|
||||
schemas: {
|
||||
public: {
|
||||
orders: {
|
||||
columns: { id: 'int', total: 'numeric' },
|
||||
rows: [
|
||||
{ id: 1, total: 10 },
|
||||
{ id: 2, total: 20 }
|
||||
]
|
||||
},
|
||||
customers: {
|
||||
columns: { id: 'int', name: 'text' },
|
||||
rows: [{ id: 1, name: 'alice' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => resetBenchmarkMockBackend())
|
||||
afterEach(() => resetBenchmarkMockBackend())
|
||||
|
||||
describe('listBenchmarkDatatables', () => {
|
||||
it('returns null for a non-benchmark workspace (caller falls through to real backend)', () => {
|
||||
expect(listBenchmarkDatatables('unregistered')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns [] for a registered workspace with no datatables seed', () => {
|
||||
registerBenchmarkWorkspaceRunnables(WORKSPACE, {})
|
||||
expect(listBenchmarkDatatables(WORKSPACE)).toEqual([])
|
||||
})
|
||||
|
||||
it('projects seeded datatables to schema -> table names only (no columns)', () => {
|
||||
registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED)
|
||||
expect(listBenchmarkDatatables(WORKSPACE)).toEqual([
|
||||
{ datatable_name: 'main', schemas: { public: ['orders', 'customers'] } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBenchmarkDatatableSchema', () => {
|
||||
beforeEach(() => registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED))
|
||||
|
||||
it('returns the columns for a seeded table', () => {
|
||||
expect(
|
||||
getBenchmarkDatatableSchema({
|
||||
workspace: WORKSPACE,
|
||||
datatableName: 'main',
|
||||
schemaName: 'public',
|
||||
tableName: 'orders'
|
||||
})
|
||||
).toEqual({
|
||||
datatable_name: 'main',
|
||||
schema_name: 'public',
|
||||
table_name: 'orders',
|
||||
columns: { id: 'int', total: 'numeric' }
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a not-configured error matching the production regex for an unknown datatable', () => {
|
||||
let error: Error | undefined
|
||||
try {
|
||||
getBenchmarkDatatableSchema({
|
||||
workspace: WORKSPACE,
|
||||
datatableName: 'ghost',
|
||||
schemaName: 'public',
|
||||
tableName: 'orders'
|
||||
})
|
||||
} catch (e) {
|
||||
error = e as Error
|
||||
}
|
||||
expect(error).toBeDefined()
|
||||
expect(error!.message).toMatch(NOT_CONFIGURED_RE)
|
||||
})
|
||||
|
||||
it('throws a table-not-found error that does NOT match the datatable-not-configured regex', () => {
|
||||
// The datatable IS configured; only the table is missing. Production maps
|
||||
// this to a generic "error getting schema", not the blocking message.
|
||||
let error: Error | undefined
|
||||
try {
|
||||
getBenchmarkDatatableSchema({
|
||||
workspace: WORKSPACE,
|
||||
datatableName: 'main',
|
||||
schemaName: 'public',
|
||||
tableName: 'ghost'
|
||||
})
|
||||
} catch (e) {
|
||||
error = e as Error
|
||||
}
|
||||
expect(error).toBeDefined()
|
||||
expect(error!.message).not.toMatch(NOT_CONFIGURED_RE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runBenchmarkDatatableSql + getBenchmarkCompletedJobResultMaybe', () => {
|
||||
beforeEach(() => registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED))
|
||||
|
||||
function exec(sql: string): { success: boolean; completed: boolean; result: unknown } {
|
||||
const jobId = runBenchmarkDatatableSql({ workspace: WORKSPACE, datatableName: 'main', sql })
|
||||
return getBenchmarkCompletedJobResultMaybe({ workspace: WORKSPACE, id: jobId })
|
||||
}
|
||||
|
||||
it('returns the canned rows of the table named in a SELECT FROM clause', () => {
|
||||
expect(exec('SELECT * FROM customers')).toEqual({
|
||||
success: true,
|
||||
completed: true,
|
||||
result: [{ id: 1, name: 'alice' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the first seeded table when the SELECT references no known table', () => {
|
||||
expect(exec('select 1').result).toEqual([
|
||||
{ id: 1, total: 10 },
|
||||
{ id: 2, total: 20 }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns [] success for DDL and DML statements without RETURNING', () => {
|
||||
expect(exec('CREATE TABLE foo (id int)').result).toEqual([])
|
||||
expect(exec('INSERT INTO orders VALUES (3, 30)').result).toEqual([])
|
||||
expect(exec('update orders set total = 0').result).toEqual([])
|
||||
})
|
||||
|
||||
it('reflects a write in a later SELECT, isolated from the shared seed', () => {
|
||||
exec('UPDATE orders SET total = 999 WHERE id = 1')
|
||||
expect((exec('SELECT * FROM orders').result as Record<string, unknown>[])).toContainEqual({
|
||||
id: 1,
|
||||
total: 999
|
||||
})
|
||||
// Registration deep-clones the seed, so the shared SEED const stays pristine.
|
||||
expect(SEED.datatables![0].schemas.public.orders.rows).toContainEqual({ id: 1, total: 10 })
|
||||
})
|
||||
|
||||
it('reflects a CREATE in list_datatables and get_datatable_table_schema', () => {
|
||||
exec('CREATE TABLE public.refunds (order_id int4, amount numeric)')
|
||||
expect(listBenchmarkDatatables(WORKSPACE)?.[0].schemas.public).toContain('refunds')
|
||||
expect(
|
||||
getBenchmarkDatatableSchema({
|
||||
workspace: WORKSPACE,
|
||||
datatableName: 'main',
|
||||
schemaName: 'public',
|
||||
tableName: 'refunds'
|
||||
}).columns
|
||||
).toEqual({ order_id: 'int4', amount: 'numeric' })
|
||||
})
|
||||
|
||||
it('throws for an unknown job id', () => {
|
||||
expect(() =>
|
||||
getBenchmarkCompletedJobResultMaybe({ workspace: WORKSPACE, id: 'does-not-exist' })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -34,15 +34,19 @@ vi.mock('$lib/gen', async () => {
|
||||
const actual = await vi.importActual<any>('$lib/gen')
|
||||
const {
|
||||
getBenchmarkCompletedJob,
|
||||
getBenchmarkCompletedJobResultMaybe,
|
||||
getBenchmarkDatatableSchema,
|
||||
getBenchmarkFlowByPath,
|
||||
getBenchmarkScriptByHash,
|
||||
getBenchmarkScriptByPath,
|
||||
hasBenchmarkWorkspace,
|
||||
listBenchmarkDatatables,
|
||||
listBenchmarkFlows,
|
||||
listBenchmarkScripts,
|
||||
createBenchmarkHttpTrigger,
|
||||
createBenchmarkSchedule,
|
||||
previewBenchmarkSchedule,
|
||||
runBenchmarkDatatableSql,
|
||||
runBenchmarkFlowByPath,
|
||||
runBenchmarkScriptPreview
|
||||
} = await import('./mockBackend')
|
||||
@@ -149,13 +153,27 @@ vi.mock('$lib/gen', async () => {
|
||||
args?: Record<string, unknown>
|
||||
path?: string
|
||||
}
|
||||
}) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? runBenchmarkScriptPreview({
|
||||
workspace: data.workspace,
|
||||
requestBody: data.requestBody ?? {}
|
||||
})
|
||||
: actual.JobService.runScriptPreview(data),
|
||||
}) => {
|
||||
if (!hasBenchmarkWorkspace(data.workspace)) {
|
||||
return actual.JobService.runScriptPreview(data)
|
||||
}
|
||||
const requestBody = data.requestBody ?? {}
|
||||
const database = requestBody.args?.database
|
||||
// Datatable SQL runs as a `postgresql` preview against `datatable://<name>`.
|
||||
// Execute it through the canned-SQL mock instead of linting it as a script.
|
||||
if (
|
||||
requestBody.language === 'postgresql' &&
|
||||
typeof database === 'string' &&
|
||||
database.startsWith('datatable://')
|
||||
) {
|
||||
return runBenchmarkDatatableSql({
|
||||
workspace: data.workspace,
|
||||
datatableName: database.slice('datatable://'.length),
|
||||
sql: requestBody.content ?? ''
|
||||
})
|
||||
}
|
||||
return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody })
|
||||
},
|
||||
runFlowByPath: async (data: {
|
||||
workspace: string
|
||||
path: string
|
||||
@@ -177,7 +195,31 @@ vi.mock('$lib/gen', async () => {
|
||||
return job
|
||||
}
|
||||
return actual.JobService.getJob(data)
|
||||
}
|
||||
},
|
||||
getCompletedJobResultMaybe: async (data: { workspace: string; id: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? getBenchmarkCompletedJobResultMaybe({ workspace: data.workspace, id: data.id })
|
||||
: actual.JobService.getCompletedJobResultMaybe(data)
|
||||
}),
|
||||
WorkspaceService: wrapService(actual.WorkspaceService, {
|
||||
listDataTableTables: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? (listBenchmarkDatatables(data.workspace) ?? [])
|
||||
: actual.WorkspaceService.listDataTableTables(data),
|
||||
getDataTableTableSchema: async (data: {
|
||||
workspace: string
|
||||
datatableName: string
|
||||
schemaName: string
|
||||
tableName: string
|
||||
}) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? getBenchmarkDatatableSchema({
|
||||
workspace: data.workspace,
|
||||
datatableName: data.datatableName,
|
||||
schemaName: data.schemaName,
|
||||
tableName: data.tableName
|
||||
})
|
||||
: actual.WorkspaceService.getDataTableTableSchema(data)
|
||||
}),
|
||||
ScheduleService: wrapService(actual.ScheduleService, {
|
||||
existsSchedule: async (data: { workspace: string; path: string }) =>
|
||||
|
||||
@@ -618,3 +618,206 @@
|
||||
- creates a Slack resource draft that references the secret variable instead of embedding the token
|
||||
- keeps both drafts under a sensible eval/global Slack-related path
|
||||
- does not create schedules, triggers, or deployed workspace changes
|
||||
|
||||
- id: global-test19-datatable-not-configured-asks-to-set-up
|
||||
prompt: |-
|
||||
Here are two newsletter signups: alice@example.com and bob@example.com.
|
||||
Save them into a workspace data table for me.
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- list_datatables
|
||||
forbiddenToolsUsed:
|
||||
- exec_datatable_sql
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- checks which data tables exist in the workspace before acting
|
||||
- recognizes that no data table is configured in this workspace
|
||||
- explains that a data table must first be set up by the user in the workspace settings (Data Tables) and is not created via SQL
|
||||
- does not run SQL, write a script, or invent a data table to work around the missing configuration
|
||||
- tells the user to configure a data table and then try again
|
||||
|
||||
- id: global-test20-datatable-no-hallucinated-main
|
||||
prompt: |-
|
||||
Pull the latest rows from the orders table in our data table so I can see recent orders.
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- list_datatables
|
||||
forbiddenToolsUsed:
|
||||
- exec_datatable_sql
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- checks which data tables exist in the workspace before querying
|
||||
- recognizes that no data table is configured in this workspace
|
||||
- does not assume a data table named "main" (or any other name) exists
|
||||
- does not run SQL against a guessed data table or fabricate order rows
|
||||
- tells the user they need to set up a data table in the workspace settings first
|
||||
|
||||
- id: global-test21-datatable-list-summarize
|
||||
prompt: |-
|
||||
What tables do we have in our workspace data table? Just give me the list.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- list_datatables
|
||||
forbiddenToolsUsed:
|
||||
- get_datatable_table_schema
|
||||
- exec_datatable_sql
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- lists the tables available in the workspace data table (orders and customers)
|
||||
- answers from the data table listing rather than fabricating table names
|
||||
- does not fetch column details or run SQL just to produce a table list
|
||||
|
||||
- id: global-test22-datatable-inspect-columns
|
||||
prompt: |-
|
||||
What columns does the orders table have in our workspace data table?
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- get_datatable_table_schema
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- inspects the orders table schema in the workspace data table
|
||||
- reports the orders columns (such as id, customer_id, total, status, created_at)
|
||||
- answers from the retrieved schema rather than guessing the columns
|
||||
|
||||
- id: global-test23-datatable-query-select
|
||||
prompt: |-
|
||||
Show me the orders in our workspace data table, including their status and total.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- exec_datatable_sql
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: exec_datatable_sql
|
||||
field: sql
|
||||
stringIncludesAnyOf:
|
||||
- select
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- runs a SELECT query against the orders table in the workspace data table
|
||||
- reports the orders returned by the query back to the user instead of fabricating data
|
||||
- does not tell the user to set up a data table, since one already exists
|
||||
|
||||
- id: global-test24-datatable-create-table
|
||||
prompt: |-
|
||||
Add a new table called refunds to our workspace data table, with an order id and a refund amount.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- exec_datatable_sql
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: exec_datatable_sql
|
||||
field: sql
|
||||
stringIncludesAnyOf:
|
||||
- create table
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- creates the refunds table with a plain CREATE TABLE statement on the data table
|
||||
- includes an order id and a refund amount column
|
||||
- treats creating the table as a normal SQL statement and does not claim a separate registration step is needed
|
||||
- does not write a script to create the table
|
||||
|
||||
- id: global-test25-datatable-mutate-rows
|
||||
prompt: |-
|
||||
Mark order number 2 as shipped in our workspace data table.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
# Headroom for inspect-schema -> UPDATE -> verify; the in-memory engine now
|
||||
# persists the write, so verification confirms on the first try (no loop).
|
||||
maxTurns: 12
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- exec_datatable_sql
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: exec_datatable_sql
|
||||
field: sql
|
||||
stringIncludesAnyOf:
|
||||
- update
|
||||
- insert into
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- runs an UPDATE on the orders table setting the status of order id 2 to shipped
|
||||
- targets only order number 2 rather than rewriting the whole table
|
||||
- confirms the change back to the user
|
||||
|
||||
- id: global-test26-datatable-script-sdk
|
||||
prompt: |-
|
||||
Write a script that reads our workspace data table and returns the total revenue across all orders.
|
||||
Leave it as an AI draft only; do not deploy or save it.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: script
|
||||
valueIncludes:
|
||||
- wmill.datatable(
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- get_instructions
|
||||
- write_script
|
||||
forbiddenToolsUsed:
|
||||
- exec_datatable_sql
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
# The judge has no datatable SDK reference and wrongly penalizes correct
|
||||
# wmill.datatable() tagged-template usage, so rely on the deterministic checks:
|
||||
# required get_instructions + write_script, forbidden exec_datatable_sql, and a
|
||||
# draft that contains wmill.datatable(.
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- writes a script (not a chat-time SQL execution) that reads the workspace data table at runtime
|
||||
- uses the wmill.datatable() SDK to query the orders table and sum the order totals
|
||||
- returns the total revenue from the script
|
||||
- leaves the result as an AI draft and does not deploy or save it
|
||||
|
||||
@@ -155,6 +155,15 @@ export interface ToolCallArgumentRule {
|
||||
field: string;
|
||||
stringStartsWithAnyOf?: string[];
|
||||
stringMustNotStartWithAnyOf?: string[];
|
||||
/**
|
||||
* Case-insensitive "contains", existential over calls: at least one recorded
|
||||
* call to `tool` must have `field` containing one of these substrings. Other
|
||||
* calls to the same tool may do anything. Use instead of `stringStartsWithAnyOf`
|
||||
* (which is universal over calls) when the meaningful token can appear anywhere
|
||||
* in the value and the model may make additional, unrelated calls to the same
|
||||
* tool — e.g. SQL where a mutation is mixed with verification SELECTs.
|
||||
*/
|
||||
stringIncludesAnyOf?: string[];
|
||||
}
|
||||
|
||||
export interface ToolValidationSpec {
|
||||
|
||||
@@ -140,6 +140,111 @@ describe("validateToolExpectations", () => {
|
||||
details: "tools used: write_script, deploy_workspace_item",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a stringIncludesAnyOf substring regardless of case or position", () => {
|
||||
const checks = validateToolExpectations({
|
||||
run: {
|
||||
success: true,
|
||||
actual: {},
|
||||
assistantMessageCount: 1,
|
||||
toolCallCount: 1,
|
||||
toolsUsed: ["exec_datatable_sql"],
|
||||
toolCallDetails: [
|
||||
{
|
||||
name: "exec_datatable_sql",
|
||||
arguments: {
|
||||
sql: "WITH recent AS (SELECT * FROM orders) SELECT count(*) FROM recent",
|
||||
},
|
||||
},
|
||||
],
|
||||
skillsInvoked: [],
|
||||
},
|
||||
toolExpect: {
|
||||
requiredToolsUsed: ["exec_datatable_sql"],
|
||||
toolCallArgs: [
|
||||
{
|
||||
tool: "exec_datatable_sql",
|
||||
field: "sql",
|
||||
stringIncludesAnyOf: ["select"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks.every((check) => check.passed)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts stringIncludesAnyOf when only one of several calls matches", () => {
|
||||
// Existential: a mutation mixed with verification SELECTs still passes.
|
||||
const checks = validateToolExpectations({
|
||||
run: {
|
||||
success: true,
|
||||
actual: {},
|
||||
assistantMessageCount: 1,
|
||||
toolCallCount: 2,
|
||||
toolsUsed: ["exec_datatable_sql"],
|
||||
toolCallDetails: [
|
||||
{
|
||||
name: "exec_datatable_sql",
|
||||
arguments: { sql: "UPDATE orders SET status = 'shipped' WHERE id = 2" },
|
||||
},
|
||||
{
|
||||
name: "exec_datatable_sql",
|
||||
arguments: { sql: "SELECT * FROM orders WHERE id = 2" },
|
||||
},
|
||||
],
|
||||
skillsInvoked: [],
|
||||
},
|
||||
toolExpect: {
|
||||
toolCallArgs: [
|
||||
{
|
||||
tool: "exec_datatable_sql",
|
||||
field: "sql",
|
||||
stringIncludesAnyOf: ["insert into", "update"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks.every((check) => check.passed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stringIncludesAnyOf when no call matches any substring", () => {
|
||||
const checks = validateToolExpectations({
|
||||
run: {
|
||||
success: true,
|
||||
actual: {},
|
||||
assistantMessageCount: 1,
|
||||
toolCallCount: 1,
|
||||
toolsUsed: ["exec_datatable_sql"],
|
||||
toolCallDetails: [
|
||||
{
|
||||
name: "exec_datatable_sql",
|
||||
arguments: {
|
||||
sql: "DROP TABLE orders",
|
||||
},
|
||||
},
|
||||
],
|
||||
skillsInvoked: [],
|
||||
},
|
||||
toolExpect: {
|
||||
toolCallArgs: [
|
||||
{
|
||||
tool: "exec_datatable_sql",
|
||||
field: "sql",
|
||||
stringIncludesAnyOf: ["insert into", "update"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks).toContainEqual({
|
||||
name: "exec_datatable_sql.sql includes a required substring",
|
||||
passed: false,
|
||||
details:
|
||||
'accepted substrings: insert into, update; values: "DROP TABLE orders"',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateGlobalState", () => {
|
||||
|
||||
@@ -222,6 +222,25 @@ export function validateToolExpectations(input: {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) {
|
||||
// Existential: at least one call must contain one of the substrings.
|
||||
// Other calls to the same tool may do anything — this suits SQL, where a
|
||||
// model mixes the requested statement (e.g. an UPDATE) with verification
|
||||
// SELECTs that would otherwise fail an "all calls" check.
|
||||
const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase());
|
||||
const hasMatch = values.some(
|
||||
(value) =>
|
||||
typeof value === "string" && needles.some((needle) => value.toLowerCase().includes(needle))
|
||||
);
|
||||
checks.push(
|
||||
check(
|
||||
`${rule.tool}.${rule.field} includes a required substring`,
|
||||
hasMatch,
|
||||
`accepted substrings: ${rule.stringIncludesAnyOf.join(", ")}; values: ${summarizeToolValues(values)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"workspace": {
|
||||
"datatables": [
|
||||
{
|
||||
"datatable_name": "main",
|
||||
"schemas": {
|
||||
"public": {
|
||||
"orders": {
|
||||
"columns": {
|
||||
"id": "int4",
|
||||
"customer_id": "int4",
|
||||
"total": "numeric",
|
||||
"status": "text",
|
||||
"created_at": "timestamptz"
|
||||
},
|
||||
"rows": [
|
||||
{ "id": 1, "customer_id": 1, "total": 42.5, "status": "shipped", "created_at": "2026-05-01T10:00:00Z" },
|
||||
{ "id": 2, "customer_id": 2, "total": 19.99, "status": "pending", "created_at": "2026-05-02T11:30:00Z" },
|
||||
{ "id": 3, "customer_id": 1, "total": 88, "status": "shipped", "created_at": "2026-05-03T09:15:00Z" }
|
||||
]
|
||||
},
|
||||
"customers": {
|
||||
"columns": {
|
||||
"id": "int4",
|
||||
"name": "text",
|
||||
"email": "text",
|
||||
"tier": "text"
|
||||
},
|
||||
"rows": [
|
||||
{ "id": 1, "name": "Alice", "email": "alice@example.com", "tier": "gold" },
|
||||
{ "id": 2, "name": "Bob", "email": "bob@example.com", "tier": "silver" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT id AS \"id!\" FROM chain",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "permissioned_as_email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c533691be8136c5ed6835c2fcdb016c257a5e4ee271d221fa961c24bd119d98e"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT hash, content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e88e1009f5359e205a523a32e4e8e72605971a3f417cb2b032c4d43652aca056"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886"
|
||||
}
|
||||
Generated
+173
-148
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -251,6 +251,7 @@ windmill-object-store.workspace = true
|
||||
windmill-git-sync.workspace = true
|
||||
windmill-api = { workspace = true, default-features = false }
|
||||
windmill-api-agent-workers = { workspace = true, optional = true }
|
||||
windmill-api-scripts.workspace = true
|
||||
windmill-api-settings.workspace = true
|
||||
windmill-worker.workspace = true
|
||||
windmill-indexer = { workspace = true, optional = true }
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Threat Model: Windmill Backend
|
||||
|
||||
## 1. System context
|
||||
|
||||
Windmill is an open-source (AGPLv3) developer platform for internal tools,
|
||||
workflows, background jobs, API integrations, and UIs — a self-hostable
|
||||
alternative to Retool / Pipedream / Airplane. The backend is a Rust workspace
|
||||
(~60 crates: `windmill-api`, `windmill-worker`, `windmill-queue`,
|
||||
`windmill-common`, a family of `windmill-trigger-*` crates, `windmill-mcp`,
|
||||
`windmill-sandbox`, etc.) fronting a PostgreSQL database. A Svelte 5 frontend
|
||||
(not in scope here, but referenced where stored-XSS threats originate) is
|
||||
served by the same instance. The product ships in a Community Edition (CE,
|
||||
public Docker images) and an Enterprise Edition (EE, `*_ee.rs` files gated by
|
||||
`enterprise`/`private`/`license` cargo features).
|
||||
|
||||
The defining characteristic for threat modeling is that **Windmill executes
|
||||
arbitrary user-supplied code** (Python, TypeScript via Bun/Deno, Go, Bash,
|
||||
SQL, GraphQL, PowerShell, Rust, …) on its workers, and **stores the
|
||||
credentials to every system its users connect to** (databases, cloud
|
||||
accounts, SaaS APIs, OAuth tokens). It is therefore simultaneously an
|
||||
arbitrary-code-execution engine and a credential vault — compromising one
|
||||
instance can pivot into an organization's entire connected estate. Crucially,
|
||||
the owner confirms `nsjail` is **off by default everywhere** (`ENABLE_NSJAIL`
|
||||
is opt-in) and network isolation (`clone_newnet`) is separately gated: the
|
||||
*only* job isolation present in a default install is PID-namespace `unshare`.
|
||||
Filesystem and outbound-network isolation are therefore absent unless an
|
||||
operator deliberately enables them, which makes "weak-by-default isolation" a
|
||||
more accurate frame than "sandbox escape" for typical deployments. Cross-tenant
|
||||
separation is enforced in software via workspace IDs, token scopes, folder
|
||||
ACLs, and Postgres row-level security; on the managed offering, sensitive
|
||||
customers can opt into dedicated DB / worker / namespace infrastructure, but
|
||||
the shared tier relies entirely on that software boundary. Administrators are
|
||||
strongly encouraged to use nsjail sandboxing and are reminded that if they don't,
|
||||
their security model is that they trust their developers that write code ran on windmill
|
||||
to not do anything TOO malicious on the workers. When the default
|
||||
database secret backend is used, only per-workspace secret *variables* are
|
||||
encrypted at rest — instance-level `global_settings` (OAuth client secrets,
|
||||
SMTP, object-store keys, license) are stored plaintext, so a database read
|
||||
yields the instance-wide credential set. Internet-facing instances are
|
||||
typically exposed directly with no built-in rate limiting or WAF.
|
||||
|
||||
It is deployed self-hosted (Docker Compose, Kubernetes/Helm, bare metal), on
|
||||
cloud providers, and as a Windmill-Labs-managed multi-tenant service. The API
|
||||
server is internet-facing in most deployments; workers pull jobs from the
|
||||
Postgres queue. The large public attack surface (a sprawling authenticated
|
||||
HTTP API, unauthenticated public-app and webhook/trigger endpoints, outbound
|
||||
HTTP from user code and proxies) combined with the high-value assets makes
|
||||
authorization-enforcement bugs, SSRF, SQL injection, and sandbox escape the
|
||||
dominant risk categories — a pattern strongly confirmed by the project's
|
||||
published advisory history (73 GHSA advisories, several rated 9.9 critical).
|
||||
|
||||
## 2. Assets
|
||||
|
||||
| asset | description | sensitivity |
|
||||
|---|---|---|
|
||||
| Workspace encryption keys | Per-workspace key (`workspace_key`) used to encrypt secret variables (MagicCrypt256); decrypts all secrets in the workspace | critical |
|
||||
| Secret variables | User secrets stored encrypted in `variable` (is_secret) | critical |
|
||||
| Resource credentials | DB passwords, cloud creds, API keys, connection strings in `resource` JSONB | critical |
|
||||
| OAuth / external-account tokens | Refresh/access tokens in `account`, MCP OAuth tables | critical |
|
||||
| User password hashes | Argon2 hashes in `password` table | critical |
|
||||
| API tokens & session cookies | Bearer tokens / cookies in `token`; superadmin & scoped tokens | critical |
|
||||
| Instance global settings | License key, JWT secret, SUPERADMIN_SECRET, SMTP, object-store + secret-backend (Vault/KMS/SM) creds in `global_settings` | critical |
|
||||
| Worker host & process integrity | The host that runs untrusted user code | critical |
|
||||
| Cross-tenant / cross-workspace isolation | The software boundary separating workspaces, folders, and tenants | critical |
|
||||
| Downstream connected systems | Windmill is a credential vault: stored creds reach external DBs, cloud accounts, SaaS | critical |
|
||||
| Script / flow / app source | Customer IP & business logic in `script`, `flow`, `app`, `raw_app` | high |
|
||||
| Job arguments, results & logs | `queue`/`completed_job` args+result, `job_logs`; routinely contain secrets | high |
|
||||
| Object store / S3 data | Files uploaded/produced by jobs | high |
|
||||
| Audit logs | `audit`/`audit_partitioned` action trail | high |
|
||||
| Service availability | API server + worker fleet uptime | high |
|
||||
| PII | User emails, group membership | medium |
|
||||
|
||||
## 3. Entry points & trust boundaries
|
||||
|
||||
| entry_point | description | trust_boundary | reachable_assets |
|
||||
|---|---|---|---|
|
||||
| EP1 Authenticated job-execution API | `jobs/run/preview`, `run/h/{hash}`, `run_flow/run_script` — runs user code on workers | authenticated user → arbitrary code on worker | Worker host, downstream systems, isolation, job args/results/logs |
|
||||
| EP2 Unauthenticated public endpoints | `apps_u/*`, `jobs_u/getupdate*`, `scripts_u`, `settings_u`, `resources_u` (`public_app_layer.rs`) | unauth HTTP → app logic & job data | Job results, scripts, secrets, PII |
|
||||
| EP3 HTTP-trigger & webhook ingestion | `/api/r/*`, GCP/Azure push, Slack callback, `capture_u/*` | untrusted webhook → job queue | Job execution integrity, worker host |
|
||||
| EP4 Message-queue / native triggers | kafka, postgres, mqtt, websocket, nats, sqs, email triggers | external broker/message → job queue | Job execution integrity, availability |
|
||||
| EP5 HTTP API authorization layer | Token/scope/RLS/folder-ACL enforcement across all workspaced routes (`windmill-api-auth`) | scoped token / low-priv user → other users' & workspaces' data | Scripts, job data, secrets, isolation |
|
||||
| EP6 AI proxy & MCP endpoints | `ai/proxy/*`, `mcp` — resolve `$var:`/resources, proxy to LLM APIs, `X-Resource-Path` | authenticated user → outbound HTTP + secret resolution | Secrets, resource creds, internal network, downstream |
|
||||
| EP7 Outbound HTTP from executors/resources | GraphQL/HTTP/Postgres executors, webhook delivery, `test_object_storage_config`, git clone, npm tarball fetch | user-controlled URL → server-side request | Cloud metadata, internal network, downstream creds |
|
||||
| EP8 SQL query builders & contextual-var substitution | App DB query builder (`whereClause`/`tags`), Postgres-trigger `where_clause`, `%%WM_*%%` interpolation, `WM_INTERNAL_DB` | user input → raw SQL | Database, connected DBs |
|
||||
| EP9 Worker sandbox | nsjail / unshare / dind / rootless podman isolating user code | user code → host & cross-tenant filesystem/network | Worker host, isolation, downstream |
|
||||
| EP10 Worker code generation / wrappers | Entrypoint override, env-var names, workspace env interpolated into generated wrapper code | user-controlled identifier → executable code | Worker host, isolation |
|
||||
| EP11 OAuth / OIDC / SAML / MCP-OAuth / logout | Login callbacks, MCP OAuth client registration, logout `rd` redirect | untrusted IdP / redirect input → session | Session tokens, accounts |
|
||||
| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover |
|
||||
| EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings |
|
||||
| EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds |
|
||||
| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS=false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets |
|
||||
| EP16 Supply chain | Cached hub scripts, GitHub workflow actions, vendored deps, Docker base image | build/update-time input → host & build integrity | Worker host, build integrity |
|
||||
| EP17 Token lifecycle | Token create/rescope/refresh, script-issued JWTs | scoped caller → broader privilege | Tokens, accounts, isolation |
|
||||
|
||||
## 4. Threats
|
||||
|
||||
| id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b |
|
||||
| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 |
|
||||
| T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 |
|
||||
| T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 |
|
||||
| T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e |
|
||||
| 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 in the default `windmill_extra` configuration | remote_unauth | EP15 | Worker host, all assets | critical | possible | unmitigated | `REQUIRE_SIGNED_DEBUG_REQUESTS` exists but defaults to false | 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) |
|
||||
| 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, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 |
|
||||
| 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 |
|
||||
| T13 | Path traversal / arbitrary file read via log-reading and MCP path endpoints (incl. symlink following) | remote_auth | EP13 | Arbitrary files on server, global settings | high | likely | partially_mitigated | traversal checks + no-symlink-follow added | GHSA-4hrf-mgvv-xp9x, bb90f4ce83, df451aa64f, ad5ec293b5, 5f2d3e6812 |
|
||||
| T14 | Privilege escalation via token rescope/refresh, script-issued JWTs, or operator-permission gaps | remote_auth | EP17, EP5 | Tokens, isolation, accounts | high | likely | partially_mitigated | monotonic-privilege enforcement on token lifecycle; SECURITY DEFINER triggers | GHSA-p62p-67xp-v775, GHSA-vv9w-wx3c-q3x2, 2ddf93de96, 865ab70c89, 33fb08cf3d |
|
||||
| T15 | Credential leakage via worker `/proc` environment and unmasked secrets in job logs | remote_auth | EP9, EP1 | DB creds, secrets, downstream | high | likely | partially_mitigated | Aho-Corasick secret masking in logs | GHSA-pmp9-9924-f9cx, 0885d8c986 |
|
||||
| T16 | Denial of service via resource exhaustion: unbounded uploads, runaway jobs, queue flooding, or trigger-message storms | remote_auth | EP1, EP3, EP4 | Service availability, worker fleet | high | likely | risk_accepted | Per-job rlimits/timeouts exist; instance-wide DoS by an authenticated tenant is largely accepted on shared self-host (operator's job to add global quotas). Hard requirement only for managed multi-tenant | |
|
||||
| T17 | Account/credential theft via unauthenticated MCP-OAuth client registration and open redirect on logout | remote_unauth | EP11 | Accounts, session tokens | high | possible | partially_mitigated | redirect-URI handling / registration hardening | GHSA-q9xg-f2v2-695g, GHSA-53xj-pvqf-wpm9, GHSA-rr8j-ffc4-pf7h, GHSA-6c5w-777m-8rv5 |
|
||||
| T18 | Account takeover via missing rate limiting / brute force on auth endpoints | remote_unauth | EP11 | Accounts | medium | likely | unmitigated | none built-in; owner confirms instances are typically exposed directly with no app-level rate limiting or WAF | GHSA-cmv6-m7wc-c87p |
|
||||
| T19 | Enterprise license bypass and account impersonation | remote_auth | EP5 | Global settings, accounts | medium | possible | unmitigated | license validation gated by `license` feature | GHSA-48j5-p323-4mpx, GHSA-pv35-65rq-w29h, GHSA-2qx7-634r-qj6r |
|
||||
| T20 | Trigger spoofing: an actor with broker/queue access injects messages that execute jobs without app-level auth | adjacent_network | EP4 | Job execution integrity, downstream | medium | possible | risk_accepted | Owner confirms trust is delegated to broker ACLs by design; no app-level message authenticity check. Anyone able to publish to a subscribed topic/queue can cause job execution | |
|
||||
| T21 | Data-in-transit interception/tampering from TLS-disabled defaults (DB `sslmode=disable`, HTTP-only Caddy) | adjacent_network | EP15 | DB creds, secrets, session tokens | medium | possible | unmitigated | docs recommend TLS; not default | |
|
||||
| T22 | Repudiation / incident blind spots from gaps in audit coverage of sensitive actions | remote_auth | EP5 | Audit logs | medium | possible | partially_mitigated | `windmill-audit` records many actions | |
|
||||
|
||||
## 5. Deprioritized
|
||||
|
||||
| threat | reason |
|
||||
|---|---|
|
||||
| Physical access to the host / cold-boot key extraction | Out of scope; deployment-environment responsibility, not addressable in this codebase |
|
||||
| Memory-safety RCE in the Rust backend itself | Rust's safety model makes this rare; no evidence in history. Note: `unsafe` FFI (duckdb) is a narrow exception folded into supply-chain/T9 |
|
||||
| Client-side-only nuisance bugs (CSS, layout) with no security impact | No asset compromised |
|
||||
| Insider with legitimate superadmin / DB-root access | Trusted role; mitigations are operational (least privilege, audit), not technical controls in scope |
|
||||
| Spoofing of a fully-trusted upstream IdP that has itself been compromised | Out of model; Windmill trusts the configured IdP by design |
|
||||
| Instance-wide DoS by an authenticated tenant on shared self-host (T16) | Risk accepted (owner): per-job rlimits/timeouts are in place; global concurrency/queue quotas are the operator's responsibility on self-host. Remains a hard requirement for the managed multi-tenant fleet |
|
||||
| Job execution triggered by an actor with legitimate broker/queue publish access (T20) | Risk accepted (owner): trigger authenticity is delegated to broker ACLs by design; consuming from a configured source and acting on its messages is the intended behavior |
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
Facts that drove the score changes above. Two were confirmed in code during
|
||||
the interview (`[Code-verified]`); the rest remain `[Owner-states]` pending a
|
||||
check.
|
||||
|
||||
- [Code-verified] nsjail is off by default in every configuration: `DISABLE_NSJAIL` defaults to `true` (`windmill-worker/src/worker.rs:346`), and `is_sandboxing_enabled()` requires `DISABLE_NSJAIL=false` or the `job_isolation` global setting = `nsjail_sandboxing` (`worker.rs:890`). PID-ns `unshare` is also off at the code level (`is_unshare_enabled()`, `worker.rs:903`); the shipped `docker-compose.yml` sets `FAVOR_UNSHARE_PID=true` (line 91), so the official compose gives PID-ns unshare only, nsjail off — a bare install gets no isolation at all. No separate `clone_newnet` flag exists; network isolation is an nsjail feature, so outbound network from user code is unrestricted by default. Affects: T2 controls/likelihood, T5 status (unmitigated), T8.
|
||||
- [Code-verified] `global_settings` is plaintext at rest under the default DB backend: `set_value_in_global_settings` stores the raw JSON value with no encryption (`windmill-common/src/global_settings.rs:259`); the encrypting secret backend (`secret_backend/database.rs:66`) only encrypts per-workspace `variable` rows with `is_secret=true`. Instance-level SMTP/OAuth/AI/object-store secrets are therefore plaintext. Affects: T6 impact/controls, T7.
|
||||
- [Owner-states] Internet-facing instances are typically exposed directly with no built-in rate limiting / WAF. Affects: T16, T18 likelihood. Verify by: confirm absence of a rate-limit layer in `windmill-api/src/lib.rs` middleware stack.
|
||||
- [Owner-states] Managed offering provides an optional dedicated DB/worker/namespace tier for sensitive tenants; the shared tier relies solely on the software authz boundary. Affects: T3 controls. Verify by: deployment topology (not in this repo) — out-of-tree.
|
||||
- [Owner-states] Per-job rlimits/timeouts exist; instance-wide DoS by an authed tenant is risk-accepted on shared self-host. Affects: T16 status. Verify by: locate the rlimit/timeout enforcement in the worker execution path and confirm there is no global queue/concurrency cap.
|
||||
- [Owner-states] Message-queue trigger authenticity is delegated to broker ACLs only. Affects: T20 status. Verify by: review `windmill-trigger-{kafka,sqs,nats,mqtt,postgres}` consume paths for any payload authentication.
|
||||
|
||||
## 7. Provenance
|
||||
|
||||
- mode: bootstrap-then-interview
|
||||
- date: 2026-06-05
|
||||
- target: /home/rfiszel/windmill/backend @ 819ba5e150
|
||||
- inputs: git-log mined + GitHub security advisories (gh api, 73 advisories) + CHANGELOG; seed: THREAT_MODEL.md (bootstrap pass)
|
||||
- owner: Ruben Fiszel (Windmill core dev)
|
||||
|
||||
## 8. Recommended mitigations
|
||||
|
||||
| mitigation | threat_ids | closes_class | effort |
|
||||
|---|---|---|---|
|
||||
| Centralize a single audited query-builder that forbids string-interpolated SQL; ban `format!`-built queries via lint/CI | T1 | yes | M |
|
||||
| Route all outbound requests through one SSRF-guarded HTTP client (allowlist/denylist of private+metadata ranges, redirects disabled, re-validated per hop) | T2 | yes | M |
|
||||
| Enforce authorization centrally in middleware (scope + RLS + folder ACL) with deny-by-default and a per-route coverage test, instead of per-handler checks | T3, T10, T14, T22 | yes | L |
|
||||
| Treat all user-supplied identifiers as data: pass via argv/env/structured params, never splice into generated wrapper source; validate against strict allowlists at the boundary | T4 | yes | M |
|
||||
| Make `nsjail` + network-namespace isolation default-on / fail-closed (flip `ENABLE_NSJAIL` and `clone_newnet` defaults) and remove privileged/dind defaults from shipped compose; default-deny debugger | T2, T5, T7, T8 | partial | L |
|
||||
| Encrypt `global_settings` at rest under the workspace/instance key even on the default DB secret backend, so a DB read no longer yields plaintext instance-wide credentials | T6, T7 | partial | M |
|
||||
| Ship hardened defaults: random per-install secrets, no default admin password, Postgres not exposed, CORS locked to configured origin, TLS-on | T7, T18, T21 | partial | M |
|
||||
| Resolve secrets/resources only with the caller's identity and scope every cache entry by (caller, scope); apply uniformly to AI proxy, MCP, and exports | T6 | yes | M |
|
||||
| Output-encode/sanitize all stored content at render and force `nosniff` + restrictive CSP on every user-content response | T11 | yes | M |
|
||||
| Verify webhook authenticity uniformly (constant-time HMAC + timestamp/nonce anti-replay) in a shared trigger-auth helper | T12 | yes | S |
|
||||
| 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 |
|
||||
@@ -1 +1 @@
|
||||
3742e0659c5e97aab03b9efeea14cd94a3ac658a
|
||||
2c7964460327fab5e3a27c0f74b8d6f26ab7f79a
|
||||
|
||||
+40
-11
@@ -154,32 +154,61 @@
|
||||
"zoho": {
|
||||
"auth_url": "https://accounts.zoho.com/oauth/v2/auth",
|
||||
"token_url": "https://accounts.zoho.com/oauth/v2/token",
|
||||
"scopes": [
|
||||
"ZohoAssist.sessionapi.ALL"
|
||||
],
|
||||
"scopes": ["ZohoAssist.sessionapi.ALL"],
|
||||
"extra_params": {
|
||||
"access_type": "offline"
|
||||
}
|
||||
},
|
||||
"snowflake_oauth": {},
|
||||
"snowflake_oauth": {
|
||||
"connect_config_template": {
|
||||
"display_name": "Snowflake",
|
||||
"label": "Snowflake Account Identifier",
|
||||
"placeholder": "<orgname>-<account_name>",
|
||||
"help_url": "https://docs.snowflake.com/en/user-guide/admin-account-identifier#using-an-account-name-as-an-identifier",
|
||||
"auth_url": "https://{instance}.snowflakecomputing.com/oauth/authorize",
|
||||
"token_url": "https://{instance}.snowflakecomputing.com/oauth/token-request",
|
||||
"req_body_auth": false,
|
||||
"extra_params_key": "account_identifier",
|
||||
"resource_mapping": { "account_identifier": "{instance}" }
|
||||
}
|
||||
},
|
||||
"apify": {
|
||||
"auth_url": "https://console.apify.com/authorize/oauth",
|
||||
"token_url": "https://console-backend.apify.com/oauth/apps/token",
|
||||
"scopes": [
|
||||
"profile",
|
||||
"full_api_access"
|
||||
],
|
||||
"scopes": ["profile", "full_api_access"],
|
||||
"extra_params": {}
|
||||
},
|
||||
"docusign": {
|
||||
"auth_url": "https://account.docusign.com/oauth/auth",
|
||||
"token_url": "https://account.docusign.com/oauth/token",
|
||||
"scopes": [
|
||||
"signature"
|
||||
],
|
||||
"scopes": ["signature"],
|
||||
"sandbox": {
|
||||
"auth_url": "https://account-d.docusign.com/oauth/auth",
|
||||
"token_url": "https://account-d.docusign.com/oauth/token"
|
||||
}
|
||||
},
|
||||
"salesforce": {
|
||||
"auth_url": "https://login.salesforce.com/services/oauth2/authorize",
|
||||
"token_url": "https://login.salesforce.com/services/oauth2/token",
|
||||
"scopes": ["api", "refresh_token", "offline_access"],
|
||||
"sandbox": {
|
||||
"auth_url": "https://test.salesforce.com/services/oauth2/authorize",
|
||||
"token_url": "https://test.salesforce.com/services/oauth2/token"
|
||||
}
|
||||
},
|
||||
"servicenow": {
|
||||
"connect_config_template": {
|
||||
"display_name": "ServiceNow",
|
||||
"label": "ServiceNow Instance",
|
||||
"placeholder": "<instance> (e.g. dev12345)",
|
||||
"help_url": "https://www.servicenow.com/docs/bundle/zurich-platform-security/page/administer/security/concept/c_OAuthApplications.html",
|
||||
"auth_url": "https://{instance}.service-now.com/oauth_auth.do",
|
||||
"token_url": "https://{instance}.service-now.com/oauth_token.do",
|
||||
"req_body_auth": true,
|
||||
"strip_suffix": ".service-now.com",
|
||||
"resource_mapping": {
|
||||
"instance_url": "https://{instance}.service-now.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,10 @@ impl Visit for ImportsFinder {
|
||||
/// See also: [`parse_relative_imports`] for resolved absolute paths.
|
||||
pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Result<Vec<String>> {
|
||||
let cm: Lrc<SourceMap> = Default::default();
|
||||
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.to_string());
|
||||
let fm = cm.new_source_file(
|
||||
FileName::Custom("main.d.ts".into()).into(),
|
||||
code.to_string(),
|
||||
);
|
||||
let mut tss = TsSyntax::default();
|
||||
tss.disallow_ambiguous_jsx_like;
|
||||
tss.tsx = true;
|
||||
|
||||
+24
-24
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6263,7 +6263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6275,7 +6275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6296,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6308,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6320,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6332,7 +6332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6344,7 +6344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6378,7 +6378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6411,7 +6411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6423,7 +6423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6437,7 +6437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6454,7 +6454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6467,7 +6467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6479,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6513,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6529,7 +6529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6561,7 +6561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.713.1"
|
||||
version = "1.719.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+32
-7
@@ -57,11 +57,14 @@ use windmill_common::{
|
||||
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING,
|
||||
SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING,
|
||||
TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING,
|
||||
UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
|
||||
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
|
||||
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING,
|
||||
SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING,
|
||||
SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING,
|
||||
SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING,
|
||||
STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
|
||||
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
|
||||
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
|
||||
WORKSPACE_REGISTRIES_SETTING,
|
||||
},
|
||||
scripts::ScriptLang,
|
||||
stats_oss::schedule_stats,
|
||||
@@ -134,8 +137,11 @@ use crate::monitor::{
|
||||
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
|
||||
reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting,
|
||||
reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
|
||||
reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting,
|
||||
reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting,
|
||||
reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting,
|
||||
reload_sandbox_registry_auth_setting, reload_scim_token_setting, reload_smtp_config,
|
||||
reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
|
||||
reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting,
|
||||
reload_worker_config, MonitorIteration,
|
||||
};
|
||||
@@ -1664,6 +1670,12 @@ async fn process_notify_event(
|
||||
match *source_type {
|
||||
"script" => {
|
||||
windmill_common::DEPLOYED_SCRIPT_HASH_CACHE.remove(&key);
|
||||
// Evict the relative-import latest-hash cache so a redeployed
|
||||
// imported script flips the content cache to its new version
|
||||
// across all replicas within a poll interval (see #6769). Keyed
|
||||
// by the bare path, matching this event's payload.
|
||||
windmill_api_scripts::scripts::RAW_SCRIPT_LATEST_HASH_CACHE
|
||||
.remove(&format!("{workspace_id}:{path}"));
|
||||
if *kind == "preprocessor" {
|
||||
match sqlx::query_scalar::<_, i64>(
|
||||
"SELECT fv.id
|
||||
@@ -1821,6 +1833,19 @@ async fn process_notify_event(
|
||||
JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await,
|
||||
NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await,
|
||||
NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await,
|
||||
SANDBOX_IMAGE_MAX_SIZE_MB_SETTING => {
|
||||
reload_sandbox_image_max_size_setting(conn).await
|
||||
}
|
||||
SANDBOX_IMAGE_CACHE_MAX_MB_SETTING => {
|
||||
reload_sandbox_image_cache_max_setting(conn).await
|
||||
}
|
||||
SANDBOX_IMAGE_PULL_POLICY_SETTING => {
|
||||
reload_sandbox_image_pull_policy_setting(conn).await
|
||||
}
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING => {
|
||||
reload_sandbox_image_default_registry_setting(conn).await
|
||||
}
|
||||
SANDBOX_REGISTRY_AUTH_SETTING => reload_sandbox_registry_auth_setting(conn).await,
|
||||
#[cfg(feature = "parquet")]
|
||||
OBJECT_STORE_CONFIG_SETTING => {
|
||||
if !disable_s3_store {
|
||||
|
||||
+72
-3
@@ -66,7 +66,9 @@ use windmill_common::{
|
||||
OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
|
||||
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
|
||||
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
|
||||
SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING,
|
||||
STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
|
||||
UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
|
||||
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
|
||||
@@ -112,8 +114,10 @@ use windmill_worker::{
|
||||
JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML,
|
||||
NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB,
|
||||
NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL,
|
||||
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER,
|
||||
UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
|
||||
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB,
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY,
|
||||
SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY,
|
||||
UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -407,6 +411,11 @@ pub async fn initial_load(
|
||||
reload_job_isolation_setting(&conn).await;
|
||||
reload_nsjail_tmpfs_size_setting(&conn).await;
|
||||
reload_nsjail_tmp_backing_setting(&conn).await;
|
||||
reload_sandbox_image_max_size_setting(&conn).await;
|
||||
reload_sandbox_image_cache_max_setting(&conn).await;
|
||||
reload_sandbox_image_pull_policy_setting(&conn).await;
|
||||
reload_sandbox_image_default_registry_setting(&conn).await;
|
||||
reload_sandbox_registry_auth_setting(&conn).await;
|
||||
reload_extra_pip_index_url_setting(&conn).await;
|
||||
reload_pip_index_url_setting(&conn).await;
|
||||
reload_uv_index_strategy_setting(&conn).await;
|
||||
@@ -2045,6 +2054,66 @@ pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_sandbox_image_max_size_setting(conn: &Connection) {
|
||||
reload_option_setting_with_tracing(
|
||||
conn,
|
||||
SANDBOX_IMAGE_MAX_SIZE_MB_SETTING,
|
||||
"SANDBOX_IMAGE_MAX_SIZE_MB",
|
||||
SANDBOX_IMAGE_MAX_SIZE_MB.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_sandbox_image_cache_max_setting(conn: &Connection) {
|
||||
reload_option_setting_with_tracing(
|
||||
conn,
|
||||
SANDBOX_IMAGE_CACHE_MAX_MB_SETTING,
|
||||
"SANDBOX_IMAGE_CACHE_MAX_MB",
|
||||
SANDBOX_IMAGE_CACHE_MAX_MB.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_sandbox_image_pull_policy_setting(conn: &Connection) {
|
||||
reload_option_setting_with_tracing(
|
||||
conn,
|
||||
SANDBOX_IMAGE_PULL_POLICY_SETTING,
|
||||
"SANDBOX_IMAGE_PULL_POLICY",
|
||||
SANDBOX_IMAGE_PULL_POLICY.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_sandbox_image_default_registry_setting(conn: &Connection) {
|
||||
reload_option_setting_with_tracing(
|
||||
conn,
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING,
|
||||
"SANDBOX_IMAGE_DEFAULT_REGISTRY",
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) {
|
||||
// Secret-aware: the value is a raw docker/podman auth.json with credentials, so
|
||||
// it must never be logged. Load directly (the generic reload_option_setting path
|
||||
// logs the value via load_option_setting_value) and only log a redacted message.
|
||||
let q =
|
||||
match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true)
|
||||
.await
|
||||
{
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let value = q.and_then(|q| serde_json::from_value::<String>(q).ok());
|
||||
let configured = value.as_ref().is_some_and(|v| !v.trim().is_empty());
|
||||
*SANDBOX_REGISTRY_AUTH.write().await = value;
|
||||
tracing::info!("Loaded setting SANDBOX_REGISTRY_AUTH (redacted), configured={configured}");
|
||||
}
|
||||
|
||||
pub async fn reload_job_isolation_setting(conn: &Connection) {
|
||||
let value =
|
||||
match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await {
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
-- Fixture for the single-job read authorization regression test
|
||||
-- (see tests/jobs_read_auth.rs).
|
||||
--
|
||||
-- Users available from `base`:
|
||||
-- test-user (admin, token SECRET_TOKEN)
|
||||
-- test-user-2 (User, token SECRET_TOKEN_2) -- owner of the secret script
|
||||
-- test-user-3 (User, token SECRET_TOKEN_3) -- the unprivileged "viewer"
|
||||
--
|
||||
-- test-user-3 is NOT a member of any folder/group granting access to
|
||||
-- `u/test-user-2/...`, so under the same RLS as `jobs/list` they cannot see any
|
||||
-- of these jobs unless they created them.
|
||||
|
||||
-- A tag-scoped token for test-user-2 (who can read both VICTIM (tag 'deno') and
|
||||
-- the flow (tag 'flow')). The `if_jobs:filter_tags:deno` modifier restricts it to
|
||||
-- the 'deno' tag, so it must NOT be able to mint a share token for the 'flow' job.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
|
||||
encode(sha256('SCOPED_DENO_TOKEN'::bytea), 'hex'), 'SCOPED_DEN', 'SCOPED_DENO_TOKEN',
|
||||
'test2@windmill.dev', 'scoped deno token', false,
|
||||
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
|
||||
);
|
||||
|
||||
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
|
||||
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
|
||||
-- running-state to a non-reader.
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner
|
||||
) VALUES (
|
||||
'77777777-7777-7777-7777-777777777777', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/running_secret', 'deno', true
|
||||
);
|
||||
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
|
||||
('77777777-7777-7777-7777-777777777777', 'test-workspace', '2023-01-01 00:00:00', true, 'deno');
|
||||
|
||||
-- 1. VICTIM job: a completed run of test-user-2's private script, e.g. produced
|
||||
-- by a public HTTP trigger. `created_by` is the route identity (test-user-2),
|
||||
-- NOT the viewer; `permissioned_as`/`runnable_path` sit in test-user-2's
|
||||
-- namespace; `visible_to_owner` is true. Its args + result carry secrets.
|
||||
-- Pre-fix, test-user-3 could read all of these by UUID.
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, args
|
||||
) VALUES (
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/secret_script', 'deno', true,
|
||||
'{"secret": "LEAK_TEST_ARGS"}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (
|
||||
id, workspace_id, duration_ms, status, result
|
||||
) VALUES (
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 1000,
|
||||
'success'::job_status, '{"secret": "RESULT_SECRET"}'
|
||||
);
|
||||
INSERT INTO public.job_logs (job_id, workspace_id, logs) VALUES
|
||||
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'secret logs LEAK_TEST_LOGS');
|
||||
|
||||
-- 2. APP-style job: run by the viewer (test-user-3) on behalf of an app whose
|
||||
-- policy executes as test-user-2. `created_by` is the launching viewer, but
|
||||
-- `permissioned_as`/`runnable_path` are the app owner's and
|
||||
-- `visible_to_owner` is false (apps hide their component runs from the runs
|
||||
-- list). This is the case that must KEEP working after the fix: the viewer
|
||||
-- polls their own component result by UUID.
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, args
|
||||
) VALUES (
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 'test-user-3',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
|
||||
'{"app_arg": "ok"}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (
|
||||
id, workspace_id, duration_ms, status, result
|
||||
) VALUES (
|
||||
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 1000,
|
||||
'success'::job_status, '{"app_result": "visible_to_launcher"}'
|
||||
);
|
||||
|
||||
-- 3. ANONYMOUS job: a public-trigger run whose creator is `anonymous`. Reading
|
||||
-- it without authentication must keep working (unchanged behavior).
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, args
|
||||
) VALUES (
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 'anonymous',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/public_trigger', 'deno', true,
|
||||
'{"public": "arg"}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (
|
||||
id, workspace_id, duration_ms, status, result
|
||||
) VALUES (
|
||||
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 1000,
|
||||
'success'::job_status, '{"public": "result"}'
|
||||
);
|
||||
|
||||
-- 4. FLOW + STEP: test-user-3 has *read* access to folder `shared` (extra_perms),
|
||||
-- so they can see flow `f/shared/flow1` (run by test-user-2) even though they
|
||||
-- did not launch it. The flow's STEP job runs the inner script
|
||||
-- `u/test-user-2/inner_secret` (test-user-3 has NO direct ACL on it) and is
|
||||
-- not in their list. Visibility must be INHERITED from the flow root: being
|
||||
-- able to see the flow means being able to inspect its steps (the flow-run UI
|
||||
-- fetches each step by id). This guards against the fix over-blocking.
|
||||
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'shared', 'Shared Folder', '{"u/test-user-2"}',
|
||||
'{"u/test-user-3": false}', 'test-user-2');
|
||||
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner
|
||||
) VALUES (
|
||||
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'flow', 'deno', 'f/shared/flow1', 'flow', true
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (
|
||||
id, workspace_id, duration_ms, status, result
|
||||
) VALUES (
|
||||
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 1000,
|
||||
'success'::job_status, '{"flow": "done"}'
|
||||
);
|
||||
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner,
|
||||
parent_job, root_job, flow_innermost_root_job, args
|
||||
) VALUES (
|
||||
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/inner_secret', 'deno', true,
|
||||
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'dddddddd-dddd-dddd-dddd-dddddddddddd',
|
||||
'dddddddd-dddd-dddd-dddd-dddddddddddd', '{"step_arg": "x"}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (
|
||||
id, workspace_id, duration_ms, status, result
|
||||
) VALUES (
|
||||
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 1000,
|
||||
'success'::job_status, '{"step": "STEP_RESULT_INHERITED"}'
|
||||
);
|
||||
|
||||
-- 5. DEEP NESTING / MIDDLE-LAYER VISIBILITY: top flow `f/secret/top` is NOT
|
||||
-- visible to test-user-3; it has a sub-flow step `f/shared/mid` that IS visible
|
||||
-- (folder `shared`); and that sub-flow has its own leaf step running
|
||||
-- `u/test-user-2/deep_secret` (not visible). The leaf's `root_job` points at the
|
||||
-- *outermost* top (not visible), so visibility must come from the *intermediate*
|
||||
-- sub-flow the user can see — which requires walking the full parent chain, not
|
||||
-- just [self, root].
|
||||
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'secret', 'Secret Folder', '{"u/test-user-2"}', '{}', 'test-user-2');
|
||||
|
||||
-- top flow (not visible to test-user-3)
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner
|
||||
) VALUES (
|
||||
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'flow', 'deno', 'f/secret/top', 'flow', true
|
||||
);
|
||||
-- intermediate sub-flow (visible via folder `shared`), child of top
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner,
|
||||
parent_job, root_job, flow_innermost_root_job
|
||||
) VALUES (
|
||||
'99999999-9999-9999-9999-999999999999', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'flow', 'deno', 'f/shared/mid', 'flow', true,
|
||||
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||
'ffffffff-ffff-ffff-ffff-ffffffffffff'
|
||||
);
|
||||
-- leaf step of the sub-flow; runnable not visible, root_job = outermost top (not visible)
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner,
|
||||
parent_job, root_job, flow_innermost_root_job
|
||||
) VALUES (
|
||||
'88888888-8888-8888-8888-888888888888', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/deep_secret', 'deno', true,
|
||||
'99999999-9999-9999-9999-999999999999', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||
'99999999-9999-9999-9999-999999999999'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
|
||||
('ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"top": "TOP_SECRET_RESULT"}'),
|
||||
('99999999-9999-9999-9999-999999999999', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"mid": "MID_RESULT"}'),
|
||||
('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"deep": "DEEP_STEP_INHERITED"}');
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- Fixture for the MCP token-exfiltration regression test.
|
||||
--
|
||||
-- Models a malicious developer (test-user-3, a plain workspace member) who:
|
||||
-- - owns an MCP resource they are allowed to read, and
|
||||
-- - points that resource's `token` field at a secret variable living in a
|
||||
-- folder they have NO access to (`f/locked`, only test-user/admin owns it).
|
||||
--
|
||||
-- The secret variable `f/locked/secret_token` itself is inserted by the test in
|
||||
-- Rust (so it is encrypted with the real workspace key); this fixture only sets
|
||||
-- up the locked folder, the resource, and their permissions.
|
||||
|
||||
-- Folder the developer cannot read (empty extra_perms, owned by admin only).
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'locked', 'Locked Folder', '{"u/test-user"}', '{}', 'test-user');
|
||||
|
||||
-- MCP resource owned by the developer (so RLS lets them read the resource),
|
||||
-- whose token references the locked secret. The URL is a non-resolvable public
|
||||
-- host so that, for an authorized caller, resolution succeeds but the later
|
||||
-- connection/SSRF step fails deterministically without network access.
|
||||
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
|
||||
VALUES (
|
||||
'test-workspace',
|
||||
'u/test-user-3/evil_mcp',
|
||||
'{"name": "evil", "url": "https://mcp.invalid.windmill.test", "token": "$var:f/locked/secret_token"}',
|
||||
'MCP resource whose token points at a locked secret',
|
||||
'mcp',
|
||||
'{}',
|
||||
'test-user-3'
|
||||
);
|
||||
@@ -2916,6 +2916,7 @@ export function main() {
|
||||
expr: "flow_env.STOP === true".to_string(),
|
||||
skip_if_stopped: true,
|
||||
error_message: None,
|
||||
error_include_result: false,
|
||||
});
|
||||
m
|
||||
};
|
||||
@@ -2966,6 +2967,92 @@ export function main() {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// stop_after_if with `error_message` + `error_include_result` should fail the
|
||||
// flow but preserve the stopping step's own result inside the raised error
|
||||
// object, i.e. `{ "error": { .., "result": <step result> } }`. With the flag off
|
||||
// (the default) the error object carries no `result`. Regression for the
|
||||
// early-stop branch in `update_flow_status_after_job_completion_internal`.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_stop_after_if_error_include_result(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let make_flow = |include_result: bool| {
|
||||
let mut m = flow_module(
|
||||
"step",
|
||||
FlowModuleValue::RawScript {
|
||||
input_transforms: Default::default(),
|
||||
language: ScriptLang::Deno,
|
||||
content: r#"
|
||||
export function main() {
|
||||
return { userErrors: ["email taken"], ok: false };
|
||||
}
|
||||
"#
|
||||
.to_string(),
|
||||
path: None,
|
||||
lock: None,
|
||||
tag: None,
|
||||
concurrency_settings: Default::default(),
|
||||
is_trigger: None,
|
||||
assets: None,
|
||||
},
|
||||
);
|
||||
m.stop_after_if = Some(windmill_common::flows::StopAfterIf {
|
||||
expr: "true".to_string(),
|
||||
skip_if_stopped: false,
|
||||
error_message: Some("API returned userErrors".to_string()),
|
||||
error_include_result: include_result,
|
||||
});
|
||||
FlowValue { modules: vec![m], same_worker: false, ..Default::default() }
|
||||
};
|
||||
|
||||
// include_result = true: result preserves both the error and the step output
|
||||
let job = RunJob::from(JobPayload::RawFlow {
|
||||
value: make_flow(true),
|
||||
path: None,
|
||||
restarted_from: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
assert!(
|
||||
!job.success,
|
||||
"flow with raised early-stop error should fail"
|
||||
);
|
||||
let result = job.json_result().unwrap();
|
||||
assert_eq!(
|
||||
result["error"]["name"], "EarlyStopError",
|
||||
"expected EarlyStopError; got {result:?}"
|
||||
);
|
||||
assert_eq!(result["error"]["message"], "API returned userErrors");
|
||||
assert_eq!(
|
||||
result["error"]["result"],
|
||||
json!({ "userErrors": ["email taken"], "ok": false }),
|
||||
"step result should be preserved under `error.result`; got {result:?}"
|
||||
);
|
||||
|
||||
// include_result = false (default behavior): result is the bare error object
|
||||
let job = RunJob::from(JobPayload::RawFlow {
|
||||
value: make_flow(false),
|
||||
path: None,
|
||||
restarted_from: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
assert!(
|
||||
!job.success,
|
||||
"flow with raised early-stop error should fail"
|
||||
);
|
||||
let result = job.json_result().unwrap();
|
||||
assert_eq!(result["error"]["name"], "EarlyStopError");
|
||||
assert!(
|
||||
result["error"].get("result").is_none(),
|
||||
"without the flag the error must not embed the step result; got {result:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// retry_if predicate sees flow_env. Regression for the two evaluate_retry
|
||||
// call sites in `update_flow_status_after_job_completion_internal` (lines
|
||||
// 1194 and 1576) which used to pass `None` for flow_env.
|
||||
@@ -3093,6 +3180,7 @@ export function main(i: number) {
|
||||
expr: "flow_env.STOP === true".to_string(),
|
||||
skip_if_stopped: true,
|
||||
error_message: None,
|
||||
error_include_result: false,
|
||||
});
|
||||
m
|
||||
};
|
||||
@@ -3143,3 +3231,84 @@ export function main() {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// stop_after_all_iters_if with `error_message` + `error_include_result` fails the
|
||||
// flow and embeds the loop's aggregated iteration results under `error.result`.
|
||||
// Covers the loop/branch-all path where `nresult` is already populated with the
|
||||
// aggregated results (distinct from the per-step fallback to `result`).
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_stop_after_all_iters_if_error_includes_result(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let inner = flow_module(
|
||||
"iter_step",
|
||||
FlowModuleValue::RawScript {
|
||||
input_transforms: [js_input("i", "flow_input.iter.value")].into(),
|
||||
language: ScriptLang::Deno,
|
||||
content: r#"
|
||||
export function main(i: number) {
|
||||
return { iter: i };
|
||||
}
|
||||
"#
|
||||
.to_string(),
|
||||
path: None,
|
||||
lock: None,
|
||||
tag: None,
|
||||
concurrency_settings: Default::default(),
|
||||
is_trigger: None,
|
||||
assets: None,
|
||||
},
|
||||
);
|
||||
|
||||
let loop_module = {
|
||||
let mut m = flow_module(
|
||||
"loop",
|
||||
FlowModuleValue::ForloopFlow {
|
||||
iterator: InputTransform::Javascript { expr: "[1, 2, 3]".to_string() },
|
||||
modules: vec![inner],
|
||||
modules_node: None,
|
||||
skip_failures: false,
|
||||
parallel: false,
|
||||
parallelism: None,
|
||||
squash: None,
|
||||
},
|
||||
);
|
||||
m.stop_after_all_iters_if = Some(windmill_common::flows::StopAfterIf {
|
||||
expr: "true".to_string(),
|
||||
skip_if_stopped: false,
|
||||
error_message: Some("loop failed".to_string()),
|
||||
error_include_result: true,
|
||||
});
|
||||
m
|
||||
};
|
||||
|
||||
let flow = FlowValue { modules: vec![loop_module], same_worker: false, ..Default::default() };
|
||||
|
||||
let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!job.success,
|
||||
"loop with a raised early-stop error should fail"
|
||||
);
|
||||
let result = job.json_result().unwrap();
|
||||
assert_eq!(result["error"]["name"], "EarlyStopError", "got {result:?}");
|
||||
assert_eq!(result["error"]["message"], "loop failed");
|
||||
// error.result holds the aggregated iteration results (one per iteration)
|
||||
let iters = result["error"]["result"].as_array().unwrap_or_else(|| {
|
||||
panic!("error.result should be an array of iteration results; got {result:?}")
|
||||
});
|
||||
let iter_values: Vec<_> = iters.iter().map(|r| r["iter"].clone()).collect();
|
||||
assert_eq!(
|
||||
iter_values,
|
||||
vec![json!(1), json!(2), json!(3)],
|
||||
"error.result should contain each iteration's output; got {result:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Regression test for the single-job read authorization bypass.
|
||||
//!
|
||||
//! The single-job read endpoints (`/jobs_u/get`, `/completed/get`,
|
||||
//! `/completed/get_result`, `/get_args`, `/get_logs`, `/getupdate`, ...) fetch a
|
||||
//! job through the root DB handle, filtered only by job id + workspace. That is
|
||||
//! required for the unauthenticated approval / public-trigger / anonymous-job
|
||||
//! flows, but for a *logged-in* user it meant any workspace member — including a
|
||||
//! plain viewer with no ACL on the runnable — could read another user's job
|
||||
//! args/result/logs simply by obtaining the job UUID, even though the same job is
|
||||
//! hidden from them in `jobs/list` (RLS-filtered) and the underlying script
|
||||
//! returns 404.
|
||||
//!
|
||||
//! The fix (`require_job_read_access`) gates the authenticated case: a caller may
|
||||
//! read a job they created (covers app components / webhooks / their own runs)
|
||||
//! or one visible to them under the same RLS as `jobs/list` (admins bypass);
|
||||
//! otherwise 404. Unauthenticated access is unchanged (anonymous jobs only).
|
||||
//!
|
||||
//! This test pins down, against the `jobs_read_auth` fixture:
|
||||
//! - a viewer is denied the victim job's full record / result / result_maybe /
|
||||
//! args / logs / live update by UUID, and the secret never appears in the
|
||||
//! body (the core fix; pre-fix these returned 200 with the secret),
|
||||
//! - the job's owner and an admin can still read it (no over-blocking),
|
||||
//! - the "app component" affordance survives: a viewer who *launched* a job
|
||||
//! (created_by) running as someone else's identity can still read its result,
|
||||
//! - unauthenticated behavior is unchanged: anonymous jobs readable, the
|
||||
//! non-anonymous victim job rejected.
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const VICTIM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
const APP_JOB: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
const ANON_JOB: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc";
|
||||
const FLOW_JOB: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd";
|
||||
const STEP_JOB: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
|
||||
// Deep nesting: top (not visible) -> mid (visible via folder) -> deep leaf.
|
||||
const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
||||
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
|
||||
// A queued/running job (no completed row) owned by test-user-2.
|
||||
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
|
||||
|
||||
// Secrets that must never leak to an unauthorized viewer.
|
||||
const RESULT_SECRET: &str = "RESULT_SECRET";
|
||||
const ARGS_SECRET: &str = "LEAK_TEST_ARGS";
|
||||
const LOGS_SECRET: &str = "LEAK_TEST_LOGS";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
|
||||
let mut req = client().get(format!("{base}/{path}"));
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let resp = req.send().await.expect("request");
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
|
||||
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u");
|
||||
// result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`.
|
||||
let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
|
||||
// The endpoints that return the victim job's sensitive data by UUID.
|
||||
let endpoints = [
|
||||
("get", format!("get/{VICTIM}")),
|
||||
("completed/get", format!("completed/get/{VICTIM}")),
|
||||
(
|
||||
"completed/get_result",
|
||||
format!("completed/get_result/{VICTIM}"),
|
||||
),
|
||||
(
|
||||
"completed/get_result_maybe",
|
||||
format!("completed/get_result_maybe/{VICTIM}"),
|
||||
),
|
||||
("get_args", format!("get_args/{VICTIM}")),
|
||||
("get_logs", format!("get_logs/{VICTIM}")),
|
||||
(
|
||||
"get_completed_logs_tail",
|
||||
format!("get_completed_logs_tail/{VICTIM}"),
|
||||
),
|
||||
("get_flow_all_logs", format!("get_flow_all_logs/{VICTIM}")),
|
||||
(
|
||||
"completed/get_timing",
|
||||
format!("completed/get_timing/{VICTIM}"),
|
||||
),
|
||||
("getupdate", format!("getupdate/{VICTIM}?only_result=true")),
|
||||
];
|
||||
|
||||
// ---- CORE REGRESSION: the viewer (test-user-3) is denied on every endpoint
|
||||
// and no secret ever appears in the body. Pre-fix these returned 200
|
||||
// and leaked the secret.
|
||||
for (name, path) in &endpoints {
|
||||
let (status, body) = get(&base, path, Some("SECRET_TOKEN_3")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"viewer must get 403 on {name} (got {status}): {body}"
|
||||
);
|
||||
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
|
||||
assert!(
|
||||
!body.contains(secret),
|
||||
"viewer response for {name} leaked `{secret}`: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The 403 for an existing-but-forbidden job carries actionable guidance
|
||||
// (request a share link), distinguishing it from a plain not-found.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(
|
||||
body.to_lowercase().contains("share"),
|
||||
"403 body should guide the user to request a share link: {body}"
|
||||
);
|
||||
|
||||
// A genuinely non-existent job is a 404, not a 403 — existence is only disclosed
|
||||
// for jobs that actually exist in the workspace.
|
||||
let missing = "00000000-0000-4000-8000-000000000000";
|
||||
let (status, _) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{missing}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"a non-existent job must be 404, not 403 (got {status})"
|
||||
);
|
||||
|
||||
// ---- NO OVER-BLOCKING: the job's owner (test-user-2) can read its result.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner must still read their own job result (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(RESULT_SECRET),
|
||||
"owner result must contain the value: {body}"
|
||||
);
|
||||
|
||||
// ---- ADMIN BYPASS: an admin (test-user) can read any job in the workspace.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}"),
|
||||
Some("SECRET_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"admin must read any job (got {status}): {body}"
|
||||
);
|
||||
assert!(body.contains(RESULT_SECRET), "admin result body: {body}");
|
||||
|
||||
// ---- APP AFFORDANCE: a viewer who LAUNCHED a job (created_by = viewer) that
|
||||
// runs as another identity (permissioned_as = test-user-2,
|
||||
// visible_to_owner = false) can still read its result. This is the app
|
||||
// component-polling path; the fix must not break it.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{APP_JOB}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"launcher must read a job they created even without ACL on the runnable (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("visible_to_launcher"),
|
||||
"launcher should get the result they polled: {body}"
|
||||
);
|
||||
|
||||
// ---- AUTHED `/jobs` endpoints in the same class: result_by_id (flow node
|
||||
// result) and get_otel_traces (job telemetry). The viewer must be denied
|
||||
// the victim by UUID. The auth gate runs before result/trace resolution,
|
||||
// so 404 here is the gate, not incidental resolution failure.
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("result_by_id/{VICTIM}/somenode"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"viewer must get 403 on result_by_id (got {status}): {body}"
|
||||
);
|
||||
assert!(!body.contains(RESULT_SECRET), "result_by_id leaked: {body}");
|
||||
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("get_otel_traces/{VICTIM}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"viewer must get 403 on get_otel_traces (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- FLOW VISIBILITY INHERITANCE: test-user-3 has folder ACL on the flow
|
||||
// `f/shared/flow1` (run by test-user-2) but did NOT launch it, and has no
|
||||
// ACL on the step's inner runnable `u/test-user-2/inner_secret`. They must
|
||||
// still be able to (a) read the flow they can see, and (b) inspect its
|
||||
// step result — visibility is inherited from the flow root. A naive
|
||||
// "same as list" gate would 404 the step and break the flow-run UI.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{FLOW_JOB}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"viewer with folder ACL must read the flow they can see (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{STEP_JOB}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"viewer must inspect a step of a flow they can see, even without ACL on the step's runnable (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("STEP_RESULT_INHERITED"),
|
||||
"step result should be returned via flow-root inheritance: {body}"
|
||||
);
|
||||
|
||||
// ---- DEEP NESTING / MIDDLE-LAYER VISIBILITY: the deep leaf's root_job is the
|
||||
// top flow (NOT visible to test-user-3), but an intermediate sub-flow
|
||||
// (f/shared/mid) IS visible. Reading the leaf must succeed via that middle
|
||||
// ancestor — i.e. the full parent chain is walked, not just [self, root].
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{DEEP_LEAF_JOB}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"deep leaf must be readable via a visible intermediate sub-flow (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("DEEP_STEP_INHERITED"),
|
||||
"deep leaf result should be returned via mid-ancestor visibility: {body}"
|
||||
);
|
||||
// ...but the top flow itself, in a folder the viewer cannot read, stays denied.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"top flow in an unreadable folder must stay denied (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
|
||||
// without a token (public trigger / public app result polling).
|
||||
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"anonymous job must remain readable unauthenticated (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- UNAUTHENTICATED, unchanged: the non-anonymous victim job is rejected
|
||||
// for an unauthenticated caller (400, the pre-existing guard).
|
||||
let (status, body) = get(&base, &format!("completed/get_result/{VICTIM}"), None).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"unauthenticated access to a non-anonymous job must stay rejected (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(RESULT_SECRET),
|
||||
"unauth body must not leak: {body}"
|
||||
);
|
||||
|
||||
// ---- SHARE READ LINK (view_token) ----
|
||||
// The owner (test-user-2) mints a share token for the victim job.
|
||||
let (status, mint_body) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{VICTIM}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner must be able to mint a share token (got {status}): {mint_body}"
|
||||
);
|
||||
let token = mint_body.trim().trim_matches('"').to_string();
|
||||
assert!(
|
||||
token.starts_with(VICTIM),
|
||||
"token must encode the job id: {token}"
|
||||
);
|
||||
|
||||
// The viewer (no ACL) can now read the victim job via the share link.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}?view_token={token}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"view_token must grant the viewer read of the shared job (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(RESULT_SECRET),
|
||||
"shared job result must be returned with a valid view_token: {body}"
|
||||
);
|
||||
// ...and its args/logs too (whole detail page).
|
||||
let (status, _) = get(
|
||||
&base,
|
||||
&format!("get_args/{VICTIM}?view_token={token}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"view_token must also grant args (got {status})"
|
||||
);
|
||||
|
||||
// The token is scoped: it does NOT authorize an unrelated job.
|
||||
let (status, _) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{ANON_JOB}?view_token={token}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"a victim-scoped token must not authorize a different job (got {status})"
|
||||
);
|
||||
|
||||
// A garbage token is rejected (falls through to the normal 404).
|
||||
let (status, _) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}?view_token={VICTIM}.deadbeef"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"an invalid view_token must not grant access (got {status})"
|
||||
);
|
||||
|
||||
// A share token authorizes the shared job's whole flow subtree: the owner mints
|
||||
// for the top secret flow, and the viewer can then read its deep leaf.
|
||||
let (status, mint_body) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner mints token for top flow (got {status}): {mint_body}"
|
||||
);
|
||||
let top_token = mint_body.trim().trim_matches('"').to_string();
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={top_token}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a flow's share token must authorize its deep descendants (got {status}): {body}"
|
||||
);
|
||||
|
||||
// A viewer who cannot read a job cannot mint a share token for it.
|
||||
let (status, _) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{TOP_SECRET_FLOW}"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"a non-reader must not be able to mint a share token (got {status})"
|
||||
);
|
||||
|
||||
// ---- TAG-SCOPED token must not mint a token outside its allowed tags ----
|
||||
// SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both
|
||||
// VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the
|
||||
// tag scope: allowed for the deno job, denied for the flow job.
|
||||
let (status, body) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{VICTIM}"),
|
||||
Some("SCOPED_DENO_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"tag-scoped token may mint for an in-scope (deno) job (got {status}): {body}"
|
||||
);
|
||||
let (status, _) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{FLOW_JOB}"),
|
||||
Some("SCOPED_DENO_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"tag-scoped token must NOT mint for an out-of-scope (flow) job (got {status})"
|
||||
);
|
||||
|
||||
// ---- USE side: a tag-scoped token must not use someone else's valid view_token
|
||||
// to read an out-of-scope job, even via handlers that don't tag-filter their
|
||||
// data query (result_by_id, get_otel_traces, get_flow_debug_info). ----
|
||||
// An unscoped owner mints a valid token for the flow (tag 'flow').
|
||||
let (status, mint_body) = get(
|
||||
&authed_base,
|
||||
&format!("job_view_token/{FLOW_JOB}"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner mints flow token (got {status}): {mint_body}"
|
||||
);
|
||||
let flow_token = mint_body.trim().trim_matches('"').to_string();
|
||||
|
||||
// The deno-scoped token presents that valid flow token to the non-tag-filtered
|
||||
// endpoints — must still be denied (flow tag is out of its scope).
|
||||
for path in [
|
||||
format!("get_otel_traces/{FLOW_JOB}?view_token={flow_token}"),
|
||||
format!("result_by_id/{FLOW_JOB}/somenode?view_token={flow_token}"),
|
||||
] {
|
||||
let (status, _) = get(&authed_base, &path, Some("SCOPED_DENO_TOKEN")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"tag-scoped token must not use a view_token to read an out-of-scope job ({path}, got {status})"
|
||||
);
|
||||
}
|
||||
|
||||
// ...but the deno-scoped token CAN use an in-scope (deno) view_token.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{VICTIM}?view_token={token}"),
|
||||
Some("SCOPED_DENO_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"tag-scoped token may use a view_token for an in-scope (deno) job (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- get_result_maybe?get_started=true must authorize before disclosing the
|
||||
// running-state of a queued (not-yet-completed) private job. ----
|
||||
// Viewer (no ACL) must be denied rather than told the job is started.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
|
||||
Some("SECRET_TOKEN_3"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"viewer must be denied the running-state of a private queued job (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("\"started\""),
|
||||
"denied response must not disclose started-state: {body}"
|
||||
);
|
||||
// The owner still gets the in-progress response.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success() && body.contains("\"started\":true"),
|
||||
"owner must see the running job as started (got {status}): {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Regression test for the MCP token-exfiltration vulnerability.
|
||||
//!
|
||||
//! `GET /api/w/{w}/resources/mcp_tools/{path}` builds an MCP client from a
|
||||
//! resource whose `token` field is a `$var:` reference. Before the fix the token
|
||||
//! was resolved with `get_secret_value_as_admin` on the bare DB pool — no RLS,
|
||||
//! no audit — so any workspace member who could read an MCP *resource* could
|
||||
//! point its token at *any* secret variable in the workspace (e.g. one in an
|
||||
//! admin-only folder) and have it decrypted and shipped as a bearer token.
|
||||
//!
|
||||
//! The fix resolves the token through the caller's permissioned path
|
||||
//! (`get_value_internal` over the authed `user_db`), so the variable RLS — the
|
||||
//! same gate as `variables/get_value` — applies and the secret read is audited.
|
||||
//!
|
||||
//! This test pins, against the `mcp_token_exfil` fixture:
|
||||
//! - a plain developer (test-user-3) who can read the MCP resource but has no
|
||||
//! access to the locked secret is DENIED (401) at token resolution, before
|
||||
//! any connection is attempted, and the secret never leaks;
|
||||
//! - an admin (test-user) clears the variable-RLS gate, the token resolves,
|
||||
//! and the request only fails later at the connect/SSRF step — proving the
|
||||
//! legitimate path still resolves the token (no over-blocking).
|
||||
//!
|
||||
//! SSRF rejection of an author-controlled URL is covered by the unit test in
|
||||
//! `windmill-mcp` (`from_resource_rejects_ssrf_url`).
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const SECRET_VALUE: &str = "S3CRET-MCP-TOKEN-VALUE";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
async fn get(base: &str, path: &str, token: &str) -> (reqwest::StatusCode, String) {
|
||||
let resp = client()
|
||||
.get(format!("{base}/{path}"))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("request");
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
|
||||
async fn test_mcp_token_not_exfiltrated(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
// Insert the locked secret variable with a real, workspace-key-encrypted
|
||||
// value so an authorized read genuinely decrypts it.
|
||||
let mc = windmill_common::variables::build_crypt(&db, "test-workspace").await?;
|
||||
let encrypted = windmill_common::variables::encrypt(&mc, SECRET_VALUE);
|
||||
// Runtime-checked query (not the `query!` macro) so no offline `.sqlx` cache
|
||||
// entry is needed for this test-only insert.
|
||||
sqlx::query(
|
||||
"INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
|
||||
VALUES ('test-workspace', 'f/locked/secret_token', $1, true, 'Locked secret', '{}')",
|
||||
)
|
||||
.bind(&encrypted)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/resources/mcp_tools");
|
||||
let path = "u/test-user-3/evil_mcp";
|
||||
|
||||
// ---- CORE REGRESSION: the developer can read the resource but must NOT be
|
||||
// able to resolve the locked secret. They are denied (401) at the
|
||||
// variable-RLS gate, before any MCP connection is attempted, and the
|
||||
// secret never appears in the response.
|
||||
let (status, body) = get(&base, path, "SECRET_TOKEN_3").await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
"developer must be denied resolving a secret they can't read (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains(SECRET_VALUE),
|
||||
"the locked secret must never leak to the developer: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("don't have access"),
|
||||
"denial should come from the variable-RLS gate, not a connection error: {body}"
|
||||
);
|
||||
// Pre-fix, the token was decrypted as admin and the handler proceeded to the
|
||||
// connection step; that path must no longer be reached for the developer.
|
||||
assert!(
|
||||
!body.contains("Failed to connect to MCP server"),
|
||||
"developer must be blocked before the connection step (would mean the token was resolved): {body}"
|
||||
);
|
||||
|
||||
// ---- NO OVER-BLOCKING: an admin clears the variable-RLS gate, so the token
|
||||
// resolves and the request only fails later at the connect/SSRF step.
|
||||
// A different failure mode (not 401, reaches the connection) proves the
|
||||
// legitimate read still works.
|
||||
let (status, body) = get(&base, path, "SECRET_TOKEN").await;
|
||||
assert_ne!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
"admin must clear the variable-RLS gate (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Failed to connect to MCP server"),
|
||||
"admin should resolve the token and only fail at the connect/SSRF step: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -507,3 +507,123 @@ async fn test_root_job_span_attributes_values() {
|
||||
assert_eq!(get_attr("workspace_id"), "test-workspace");
|
||||
assert_eq!(get_attr("script_path"), "f/test/script");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// INBOUND TRACE CONTEXT (W3C traceparent → span link)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
const SAMPLE_TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
|
||||
|
||||
fn sample_trace_id() -> opentelemetry::trace::TraceId {
|
||||
opentelemetry::trace::TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap()
|
||||
}
|
||||
|
||||
fn sample_span_id() -> opentelemetry::trace::SpanId {
|
||||
opentelemetry::trace::SpanId::from_hex("b7ad6b7169203331").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_span_cx_from_traceparent_valid() {
|
||||
let cx = span_cx_from_traceparent(SAMPLE_TRACEPARENT).expect("valid traceparent");
|
||||
assert_eq!(cx.trace_id(), sample_trace_id());
|
||||
assert_eq!(cx.span_id(), sample_span_id());
|
||||
assert!(cx.is_remote());
|
||||
assert!(cx.is_sampled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_span_cx_from_traceparent_unsampled_flag() {
|
||||
let cx = span_cx_from_traceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00")
|
||||
.expect("valid traceparent");
|
||||
assert!(!cx.is_sampled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_span_cx_from_traceparent_malformed() {
|
||||
for bad in [
|
||||
"",
|
||||
"garbage",
|
||||
"00-tooshort-b7ad6b7169203331-01",
|
||||
// missing flags field
|
||||
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331",
|
||||
// trailing extra field
|
||||
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra",
|
||||
// all-zero trace id / span id are invalid per the spec
|
||||
"00-00000000000000000000000000000000-b7ad6b7169203331-01",
|
||||
"00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01",
|
||||
// non-hex
|
||||
"00-zzf7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
] {
|
||||
assert!(
|
||||
span_cx_from_traceparent(bad).is_none(),
|
||||
"expected None for {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn job_with_traceparent(tp: Option<&str>) -> windmill_queue::MiniPulledJob {
|
||||
let mut job = make_test_job(uuid::Uuid::new_v4(), None);
|
||||
if let Some(tp) = tp {
|
||||
let mut args = std::collections::HashMap::new();
|
||||
args.insert(
|
||||
windmill_common::jobs::WM_TRACEPARENT.to_string(),
|
||||
windmill_common::worker::to_raw_value(&tp),
|
||||
);
|
||||
job.args = Some(sqlx::types::Json(args));
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inbound_span_cx_from_job_present() {
|
||||
let job = job_with_traceparent(Some(SAMPLE_TRACEPARENT));
|
||||
let cx = windmill_worker::otel_ee::inbound_span_cx_from_job(&job).expect("link expected");
|
||||
assert_eq!(cx.trace_id(), sample_trace_id());
|
||||
assert_eq!(cx.span_id(), sample_span_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inbound_span_cx_from_job_absent_or_malformed() {
|
||||
// No reserved key (e.g. a flow step or internally-created job) → no link.
|
||||
assert!(
|
||||
windmill_worker::otel_ee::inbound_span_cx_from_job(&job_with_traceparent(None)).is_none()
|
||||
);
|
||||
// Malformed header is ignored rather than producing a bogus link.
|
||||
assert!(
|
||||
windmill_worker::otel_ee::inbound_span_cx_from_job(&job_with_traceparent(Some("garbage")))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_root_job_span_relocated_to_inbound_trace() {
|
||||
let state = ensure_setup().await;
|
||||
state.span_exporter.reset();
|
||||
|
||||
let job = job_with_traceparent(Some(SAMPLE_TRACEPARENT));
|
||||
let job_id = job.id;
|
||||
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
||||
|
||||
let spans = state.span_exporter.get_finished_spans().unwrap();
|
||||
let span = spans
|
||||
.iter()
|
||||
.find(|s| s.name == "full_job")
|
||||
.expect("full_job span not found");
|
||||
|
||||
// Relocated into the inbound trace, keeping the job-UUID-derived span id and
|
||||
// parented on the inbound caller span.
|
||||
assert_eq!(span.span_context.trace_id(), sample_trace_id());
|
||||
let expected_span_id =
|
||||
opentelemetry::trace::SpanId::from_bytes(job_id.as_u64_pair().1.to_be_bytes());
|
||||
assert_eq!(span.span_context.span_id(), expected_span_id);
|
||||
assert_eq!(span.parent_span_id, sample_span_id());
|
||||
|
||||
// Linked back to the UUID-derived context so trace-by-job-id still resolves.
|
||||
assert_eq!(span.links.links.len(), 1);
|
||||
let expected_uuid_trace =
|
||||
opentelemetry::trace::TraceId::from_bytes(job_id.as_u128().to_be_bytes());
|
||||
assert_eq!(
|
||||
span.links.links[0].span_context.trace_id(),
|
||||
expected_uuid_trace
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Regression tests for WIN-2007.
|
||||
*
|
||||
* Previewing a TypeScript script carrying the `//native` annotation used to be
|
||||
* pushed with `language = bun` (what the editor sends), so the job was tagged
|
||||
* `bun` and routed to a regular bun worker. A native-mode worker neither matches
|
||||
* the `bun` tag nor accepts a non-native `script_lang`, so previewing a `//native`
|
||||
* script on a native-only worker setup failed even though the *deployed* version
|
||||
* of the same script runs fine (as `bunnative` / tag `nativets`).
|
||||
*
|
||||
* `push` now reconciles the preview language with the `//native` annotation,
|
||||
* mirroring the deploy-time logic in `worker_lockfiles`. These tests assert the
|
||||
* queued job ends up with the right `script_lang` and `tag` for every combination
|
||||
* of declared language and annotation. No worker is spawned — we only inspect the
|
||||
* row `push` writes.
|
||||
*/
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::{
|
||||
jobs::{JobPayload, RawCode},
|
||||
scripts::ScriptLang,
|
||||
};
|
||||
use windmill_queue::PushIsolationLevel;
|
||||
|
||||
async fn push_preview_and_get_row(
|
||||
db: &Pool<Postgres>,
|
||||
content: &str,
|
||||
language: ScriptLang,
|
||||
) -> (String, Option<ScriptLang>) {
|
||||
let hm_args = std::collections::HashMap::new();
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: content.to_string(),
|
||||
path: None,
|
||||
language,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
});
|
||||
|
||||
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
|
||||
let (uuid, tx) = windmill_queue::push(
|
||||
db,
|
||||
tx,
|
||||
"test-workspace",
|
||||
job,
|
||||
windmill_queue::PushArgs::from(&hm_args),
|
||||
/* user */ "test-user",
|
||||
/* email */ "test@windmill.dev",
|
||||
/* permissioned_as */ "u/test-user".to_string(),
|
||||
/* token_prefix */ None,
|
||||
/* scheduled_for */ None,
|
||||
/* schedule_path */ None,
|
||||
/* parent_job */ None,
|
||||
/* root_job */ None,
|
||||
/* flow_innermost_root_job */ None,
|
||||
/* job_id */ None,
|
||||
/* is_flow_step */ false,
|
||||
/* same_worker */ false,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT tag, script_lang AS "script_lang: ScriptLang" FROM v2_job WHERE id = $1"#,
|
||||
uuid
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap();
|
||||
(row.tag, row.script_lang)
|
||||
}
|
||||
|
||||
const NATIVE_CONTENT: &str = r#"//native
|
||||
|
||||
export function main(x: number) {
|
||||
return x;
|
||||
}
|
||||
"#;
|
||||
|
||||
const PLAIN_CONTENT: &str = r#"export function main(x: number) {
|
||||
return x;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The reported case: editor sends `bun`, content has `//native`. The preview
|
||||
/// must be promoted to `bunnative` so it tags `nativets` and a native worker
|
||||
/// (which rejects non-native `script_lang`) can run it.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_bun_with_native_annotation_becomes_nativets(db: Pool<Postgres>) {
|
||||
let (tag, lang) = push_preview_and_get_row(&db, NATIVE_CONTENT, ScriptLang::Bun).await;
|
||||
assert_eq!(lang, Some(ScriptLang::Bunnative));
|
||||
assert_eq!(tag, "nativets");
|
||||
}
|
||||
|
||||
/// Guard: a plain bun preview (no `//native`) must stay `bun` / tag `bun`, so
|
||||
/// the promotion above doesn't broadly retag normal previews.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_bun_without_native_annotation_stays_bun(db: Pool<Postgres>) {
|
||||
let (tag, lang) = push_preview_and_get_row(&db, PLAIN_CONTENT, ScriptLang::Bun).await;
|
||||
assert_eq!(lang, Some(ScriptLang::Bun));
|
||||
assert_eq!(tag, "bun");
|
||||
}
|
||||
@@ -16,7 +16,8 @@ use axum::{
|
||||
};
|
||||
use windmill_api_auth::{
|
||||
auth::{list_tokens_internal, TruncatedTokenWithEmail},
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
|
||||
ApiAuthed,
|
||||
};
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
use windmill_common::{
|
||||
@@ -107,9 +108,10 @@ async fn list_search_flows(
|
||||
let n = 3;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let allowed = build_scope_path_predicate(&authed, "flows", "read");
|
||||
let rows = sqlx::query_as::<_, SearchFlow>(
|
||||
"SELECT flow.path, flow_version.value
|
||||
FROM flow
|
||||
FROM flow
|
||||
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.workspace_id = $1 LIMIT $2",
|
||||
)
|
||||
@@ -118,6 +120,7 @@ async fn list_search_flows(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
@@ -213,9 +216,13 @@ async fn list_flows(
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "flows", "read");
|
||||
let mut rows = sqlx::query_as::<_, ListableFlow>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
|
||||
// Draft-only rows: drafts the authed user has at paths with no
|
||||
|
||||
@@ -35,6 +35,9 @@ anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
futures.workspace = true
|
||||
rand.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
rumqttc.workspace = true
|
||||
rdkafka.workspace = true
|
||||
async-nats.workspace = true
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Fixture for the resource-value interpolation cache RLS regression test.
|
||||
-- Extends base.sql (which defines test-user [admin], test-user-2, test-user-3
|
||||
-- and their tokens).
|
||||
--
|
||||
-- A folder `secret` is readable ONLY by test-user-2 (via extra_perms). It holds a
|
||||
-- variable and a resource that interpolates it. test-user-3 has no access to the
|
||||
-- folder, so a cache entry warmed by test-user-2 with allow_cache=true must never
|
||||
-- be served back to test-user-3.
|
||||
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'secret', 'Secret Folder', '{}',
|
||||
'{"u/test-user-2": true}', 'test-user');
|
||||
|
||||
-- A (non-secret) variable gated to the `secret` folder; its value gets interpolated
|
||||
-- into the resource value below and ends up in the cached, already-resolved blob.
|
||||
INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
|
||||
VALUES ('test-workspace', 'f/secret/db_password', 'LEAKED_FOLDER_SECRET', false,
|
||||
'Folder-gated secret', '{}');
|
||||
|
||||
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'f/secret/cache_target',
|
||||
'{"host": "db.internal", "password": "$var:f/secret/db_password"}',
|
||||
'Folder-gated resource referencing a folder-gated variable', 'object', '{}', 'test-user');
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Fixture for the variable-value cache RLS regression test.
|
||||
-- Extends base.sql (which defines test-user [admin], test-user-2, test-user-3
|
||||
-- and their tokens).
|
||||
--
|
||||
-- A folder `secret` is readable ONLY by test-user-2 (via extra_perms). It holds a
|
||||
-- variable that test-user-2 can read but test-user-3 cannot. A cache entry warmed
|
||||
-- by test-user-2 with allow_cache=true must never be served back to test-user-3.
|
||||
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'secret', 'Secret Folder', '{}',
|
||||
'{"u/test-user-2": true}', 'test-user');
|
||||
|
||||
INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
|
||||
VALUES ('test-workspace', 'f/secret/cache_target_var', 'LEAKED_VAR_SECRET', false,
|
||||
'Folder-gated variable', '{}');
|
||||
@@ -276,3 +276,98 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test for GHSA-2ppx-66jv-wpw5: a path-scoped token must only see
|
||||
/// the flows within its scope when listing, even though the route-level scope
|
||||
/// check only validates `domain:action`. Before the fix, `list_search` returned
|
||||
/// `path` + the full flow `value` for every flow the underlying user could see,
|
||||
/// leaking out-of-scope flow definitions to narrowly-scoped tokens.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_list_search_scope_filtering(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/flows");
|
||||
|
||||
// Create two folders and one flow in each, as the (super-admin) test user.
|
||||
for folder in ["allowed", "private"] {
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/folders/create"
|
||||
)))
|
||||
.json(&json!({ "name": folder }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "create folder: {}", resp.text().await?);
|
||||
}
|
||||
|
||||
for path in ["f/allowed/foo", "f/private/bar"] {
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&new_flow(path, "summary"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?);
|
||||
}
|
||||
|
||||
// Helper: GET /list_search with an arbitrary bearer token, returning the set
|
||||
// of flow paths visible to that token.
|
||||
async fn list_search_paths(port: u16, token: &str) -> Vec<String> {
|
||||
let resp = client()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/flows/list_search"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
resp.json::<Vec<serde_json::Value>>()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s["path"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Insert three tokens for the same super-admin user, differing only by scope.
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES
|
||||
(encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', 'test@windmill.dev', 'scoped', true, ARRAY['flows:read:f/allowed/*']),
|
||||
(encode(sha256('BROAD_TOKEN'::bytea), 'hex'), 'BROAD_TOK', 'BROAD_TOKEN', 'test@windmill.dev', 'broad', true, ARRAY['flows:read']),
|
||||
(encode(sha256('TAG_TOKEN'::bytea), 'hex'), 'TAG_TOK', 'TAG_TOKEN', 'test@windmill.dev', 'tag-only', true, ARRAY['if_jobs:filter_tags:default'])",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Path-scoped token: only sees flows within `f/allowed/*`.
|
||||
let scoped = list_search_paths(port, "SCOPED_TOKEN").await;
|
||||
assert!(
|
||||
scoped.contains(&"f/allowed/foo".to_string()),
|
||||
"scoped token should see f/allowed/foo, got: {scoped:?}"
|
||||
);
|
||||
assert!(
|
||||
!scoped.contains(&"f/private/bar".to_string()),
|
||||
"scoped token must NOT see f/private/bar, got: {scoped:?}"
|
||||
);
|
||||
|
||||
// Broad `flows:read` token: still sees every RLS-visible flow.
|
||||
let broad = list_search_paths(port, "BROAD_TOKEN").await;
|
||||
assert!(broad.contains(&"f/allowed/foo".to_string()));
|
||||
assert!(
|
||||
broad.contains(&"f/private/bar".to_string()),
|
||||
"broad flows:read token should see all flows, got: {broad:?}"
|
||||
);
|
||||
|
||||
// Tag-filter-only token is not scope-restricted: unchanged, sees all.
|
||||
let tag_only = list_search_paths(port, "TAG_TOKEN").await;
|
||||
assert!(tag_only.contains(&"f/allowed/foo".to_string()));
|
||||
assert!(tag_only.contains(&"f/private/bar".to_string()));
|
||||
|
||||
// Unscoped token (no scopes column set): unchanged, sees all.
|
||||
let unscoped = list_search_paths(port, "SECRET_TOKEN").await;
|
||||
assert!(unscoped.contains(&"f/allowed/foo".to_string()));
|
||||
assert!(unscoped.contains(&"f/private/bar".to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -477,6 +477,117 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test: the resource-value interpolation cache
|
||||
/// (`get_value_interpolated?allow_cache=true`) must be identity-scoped. test-user-2
|
||||
/// (folder access) warms the cache; test-user-3 (no access) must then be denied rather
|
||||
/// than served the cached, already-decrypted value. Pre-fix the unscoped key returned
|
||||
/// a 200 with the secret here.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "resource_cache_rls"))]
|
||||
async fn test_resource_value_cache_is_identity_scoped(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let url = format!(
|
||||
"{}?allow_cache=true",
|
||||
resource_url(port, "get_value_interpolated", "f/secret/cache_target")
|
||||
);
|
||||
let get = |token: &str| {
|
||||
client()
|
||||
.get(url.as_str())
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
};
|
||||
|
||||
// test-user-2 has folder access and WARMS the cache.
|
||||
let resp = get("SECRET_TOKEN_2").send().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.text().await?.contains("LEAKED_FOLDER_SECRET"));
|
||||
|
||||
// test-user-3 has no folder access: must miss the cache and be denied (401), not leak.
|
||||
let resp = get("SECRET_TOKEN_3").send().await?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
assert!(!resp.text().await?.contains("LEAKED_FOLDER_SECRET"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is
|
||||
/// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the
|
||||
/// placeholder is left unresolved (caching that would serve a stale placeholder to a later job
|
||||
/// read). Any other value — plain, or a non-`$WM_` `$`-string like `$HOME` (which is NOT
|
||||
/// interpolated, so it's constant) — is job-independent and IS cached, with the entry shared
|
||||
/// across job contexts (a read carrying a `job_id` still hits it, keeping the hit ratio up).
|
||||
/// We prove all three by warming each (no job_id), deleting the row directly (cache survives),
|
||||
/// then re-reading: the job-independent ones are still served from cache — even under a
|
||||
/// `job_id` — while the `$WM_*` one was never cached and 404s.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_resource_cache_handles_job_context(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/resources");
|
||||
|
||||
let plain = "u/test-user/plain_res";
|
||||
let dollar = "u/test-user/dollar_res"; // non-$WM_ `$`-string: not interpolated, cacheable
|
||||
let jobctx = "u/test-user/jobctx_res";
|
||||
for (path, value) in [
|
||||
(plain, json!({"v": 1})),
|
||||
(dollar, json!({"d": "$HOME"})),
|
||||
(jobctx, json!({"j": "$WM_JOB_ID"})),
|
||||
] {
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(
|
||||
&json!({ "path": path, "value": value, "description": "", "resource_type": "object" }),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
}
|
||||
|
||||
let get = |path: &str, query: &str| {
|
||||
let url = format!("{base}/get_value_interpolated/{path}?{query}");
|
||||
async move { authed(client().get(url)).send().await.unwrap() }
|
||||
};
|
||||
|
||||
// Warm all three WITHOUT a job context (the placeholder is left unresolved for `jobctx`).
|
||||
for path in [plain, dollar, jobctx] {
|
||||
assert_eq!(get(path, "allow_cache=true").await.status(), 200);
|
||||
}
|
||||
|
||||
// Delete the rows directly — bypasses the API/NOTIFY, so the in-memory cache survives.
|
||||
for path in [plain, dollar, jobctx] {
|
||||
sqlx::query("DELETE FROM resource WHERE workspace_id = 'test-workspace' AND path = $1")
|
||||
.bind(path)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Job-independent values are cached and still served even under a job_id (a random uuid is
|
||||
// fine: a cache hit short-circuits before any job lookup). `$HOME` is a non-`$WM_` string,
|
||||
// so it's not interpolated and stays cacheable.
|
||||
for path in [plain, dollar] {
|
||||
let resp = get(
|
||||
path,
|
||||
"allow_cache=true&job_id=11111111-1111-4111-8111-111111111111",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"job-independent resource ({path}) must stay cached and be served under a job_id"
|
||||
);
|
||||
}
|
||||
|
||||
// The `$WM_*` resource was never cached → the (now deleted) row is not found.
|
||||
let resp = get(jobctx, "allow_cache=true").await;
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
200,
|
||||
"resource with a $WM_* contextual variable must not be cached"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))]
|
||||
async fn test_mcp_tools(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
@@ -457,3 +457,107 @@ async fn test_auto_parent_resolves_parent_hash(db: Pool<Postgres>) -> anyhow::Re
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test for GHSA-2ppx-66jv-wpw5: a path-scoped token must only see
|
||||
/// the scripts within its scope when listing, even though the route-level scope
|
||||
/// check only validates `domain:action`. Before the fix, `list_search` (and
|
||||
/// `list`) returned `path` + full `content` for every script the underlying
|
||||
/// user could see, leaking out-of-scope script source to narrowly-scoped tokens.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_list_search_scope_filtering(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/scripts");
|
||||
|
||||
// Create two folders and one script in each, as the (super-admin) test user.
|
||||
for folder in ["allowed", "private"] {
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/folders/create"
|
||||
)))
|
||||
.json(&json!({ "name": folder }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "create folder: {}", resp.text().await?);
|
||||
}
|
||||
|
||||
for (path, content) in [
|
||||
(
|
||||
"f/allowed/foo",
|
||||
"export async function main() { return 'allowed'; }",
|
||||
),
|
||||
(
|
||||
"f/private/bar",
|
||||
"export async function main() { return 'secret'; }",
|
||||
),
|
||||
] {
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&new_script(path, "summary", content))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?);
|
||||
}
|
||||
|
||||
// Helper: GET /list_search with an arbitrary bearer token, returning the set
|
||||
// of script paths visible to that token.
|
||||
async fn list_search_paths(port: u16, token: &str) -> Vec<String> {
|
||||
let resp = client()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/list_search"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
resp.json::<Vec<serde_json::Value>>()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s["path"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Insert three tokens for the same super-admin user, differing only by scope.
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES
|
||||
(encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', 'test@windmill.dev', 'scoped', true, ARRAY['scripts:read:f/allowed/*']),
|
||||
(encode(sha256('BROAD_TOKEN'::bytea), 'hex'), 'BROAD_TOK', 'BROAD_TOKEN', 'test@windmill.dev', 'broad', true, ARRAY['scripts:read']),
|
||||
(encode(sha256('TAG_TOKEN'::bytea), 'hex'), 'TAG_TOK', 'TAG_TOKEN', 'test@windmill.dev', 'tag-only', true, ARRAY['if_jobs:filter_tags:default'])",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Path-scoped token: only sees scripts within `f/allowed/*`.
|
||||
let scoped = list_search_paths(port, "SCOPED_TOKEN").await;
|
||||
assert!(
|
||||
scoped.contains(&"f/allowed/foo".to_string()),
|
||||
"scoped token should see f/allowed/foo, got: {scoped:?}"
|
||||
);
|
||||
assert!(
|
||||
!scoped.contains(&"f/private/bar".to_string()),
|
||||
"scoped token must NOT see f/private/bar, got: {scoped:?}"
|
||||
);
|
||||
|
||||
// Broad `scripts:read` token: still sees every RLS-visible script.
|
||||
let broad = list_search_paths(port, "BROAD_TOKEN").await;
|
||||
assert!(broad.contains(&"f/allowed/foo".to_string()));
|
||||
assert!(
|
||||
broad.contains(&"f/private/bar".to_string()),
|
||||
"broad scripts:read token should see all scripts, got: {broad:?}"
|
||||
);
|
||||
|
||||
// Tag-filter-only token is not scope-restricted: unchanged, sees all.
|
||||
let tag_only = list_search_paths(port, "TAG_TOKEN").await;
|
||||
assert!(tag_only.contains(&"f/allowed/foo".to_string()));
|
||||
assert!(tag_only.contains(&"f/private/bar".to_string()));
|
||||
|
||||
// Unscoped token (no scopes column set): unchanged, sees all.
|
||||
let unscoped = list_search_paths(port, "SECRET_TOKEN").await;
|
||||
assert!(unscoped.contains(&"f/allowed/foo".to_string()));
|
||||
assert!(unscoped.contains(&"f/private/bar".to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Regression tests for GHSA-vm75-gmpw-rvp9: the unauthenticated `/api/slack` callback must
|
||||
//! not be drivable into decrypting arbitrary workspace variables.
|
||||
//!
|
||||
//! The OpenModal branch reaches `get_slack_token` (a privileged, RLS-bypassing variable
|
||||
//! decryption). It is now gated by a per-workspace HMAC over (w_id, job_id, path) — the same
|
||||
//! workspace key used to sign resume URLs. Without a valid signature the request is rejected
|
||||
//! with 401 before any decryption, even when `SLACK_SIGNING_SECRET` is unset (the default).
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
/// Re-implementation of the server's `sign_slack_payload` for the positive-control test.
|
||||
/// The fixture sets `workspace_key.key = 'test-key'` for `test-workspace`.
|
||||
fn sign(w_id: &str, parts: &[&[u8]]) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(b"test-key").unwrap();
|
||||
mac.update(b"slack_payload_v1\0"); // SLACK_PAYLOAD_HMAC_DOMAIN
|
||||
mac.update(w_id.as_bytes());
|
||||
for p in parts {
|
||||
mac.update(b"\0");
|
||||
mac.update(p);
|
||||
}
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// POST an `open_modal` block action to the unauthenticated `/api/slack` callback.
|
||||
async fn post_open_modal(port: u16, value: serde_json::Value) -> reqwest::Response {
|
||||
let payload = json!({
|
||||
"type": "block_actions",
|
||||
"trigger_id": "trigger-123",
|
||||
"container": { "message_ts": "0", "channel_id": "C1" },
|
||||
"actions": [ { "action_id": "open_modal", "value": value.to_string() } ],
|
||||
});
|
||||
client()
|
||||
.post(format!("http://localhost:{port}/api/slack"))
|
||||
.form(&[("payload", payload.to_string())])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// POST a `view_submission` to the unauthenticated `/api/slack` callback with the given
|
||||
/// private_metadata.
|
||||
async fn post_view_submission(port: u16, private_metadata: serde_json::Value) -> reqwest::Response {
|
||||
let payload = json!({
|
||||
"type": "view_submission",
|
||||
"view": {
|
||||
"state": { "values": {} },
|
||||
"private_metadata": private_metadata.to_string(),
|
||||
},
|
||||
});
|
||||
client()
|
||||
.post(format!("http://localhost:{port}/api/slack"))
|
||||
.form(&[("payload", payload.to_string())])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A submission with an unsigned (or tampered) `private_metadata` must be rejected with 401
|
||||
/// BEFORE the resume/cancel action runs — the signature gate is checked first. The resume_url
|
||||
/// here is well-formed (so it parses) but never acted upon.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_view_submission_without_signature_is_rejected(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let job_id = Uuid::new_v4();
|
||||
let resume_url = format!("/api/w/test-workspace/jobs_u/resume/{job_id}/1/deadbeef");
|
||||
|
||||
let resp = post_view_submission(
|
||||
port,
|
||||
json!({
|
||||
"resume_url": resume_url,
|
||||
"resource_path": "u/admin/secret",
|
||||
"container": { "message_ts": "0", "channel_id": "C1" },
|
||||
"hide_cancel": false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"unsigned submission must be rejected before the resume action"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_open_modal_without_signature_is_rejected(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
// No signature → must be rejected with 401 before any variable lookup. Before the fix
|
||||
// this reached `get_slack_token` and forced decryption of `u/admin/secret`.
|
||||
let resp = post_open_modal(
|
||||
port,
|
||||
json!({
|
||||
"w_id": "test-workspace",
|
||||
"job_id": job_id.to_string(),
|
||||
"path": "u/admin/secret",
|
||||
"flow_step_id": "a",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"unsigned OpenModal callback must be rejected"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_open_modal_with_wrong_signature_is_rejected(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
let resp = post_open_modal(
|
||||
port,
|
||||
json!({
|
||||
"w_id": "test-workspace",
|
||||
"job_id": job_id.to_string(),
|
||||
"path": "u/admin/secret",
|
||||
"flow_step_id": "a",
|
||||
"signature": "deadbeef",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"OpenModal callback with an invalid signature must be rejected"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_open_modal_with_tampered_path_is_rejected(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
// A signature legitimately minted for one path cannot be reused to decrypt another: the
|
||||
// path is bound into the HMAC.
|
||||
let signature = sign(
|
||||
"test-workspace",
|
||||
&[job_id.to_string().as_bytes(), b"u/admin/legit_resource"],
|
||||
);
|
||||
let resp = post_open_modal(
|
||||
port,
|
||||
json!({
|
||||
"w_id": "test-workspace",
|
||||
"job_id": job_id.to_string(),
|
||||
"path": "u/admin/some_other_secret",
|
||||
"flow_step_id": "a",
|
||||
"signature": signature,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a signature bound to a different path must not authorize decryption"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_open_modal_with_valid_signature_passes_the_gate(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let job_id = Uuid::new_v4();
|
||||
let path = "u/admin/nonexistent_resource";
|
||||
|
||||
// A correctly signed payload passes the authorization gate and proceeds to resolve the
|
||||
// slack resource. The resource does not exist, so the handler returns a generic 400
|
||||
// ("Invalid Slack callback request") rather than 401 — proving the gate accepted the
|
||||
// signature (so the fix does not simply reject everything) without echoing the path.
|
||||
let signature = sign(
|
||||
"test-workspace",
|
||||
&[job_id.to_string().as_bytes(), path.as_bytes()],
|
||||
);
|
||||
let resp = post_open_modal(
|
||||
port,
|
||||
json!({
|
||||
"w_id": "test-workspace",
|
||||
"job_id": job_id.to_string(),
|
||||
"path": path,
|
||||
"flow_step_id": "a",
|
||||
"signature": signature,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"validly signed callback should pass the gate and 400 on the missing resource, got {status}: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("nonexistent_resource"),
|
||||
"error must not echo the probed path: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Regression tests for GHSA-8x8x-88qc-qp4r: token label collision bypassing job read
|
||||
//! access control (IDOR).
|
||||
//!
|
||||
//! `username_override` is derived from a fully user-controlled token label, so a bare
|
||||
//! `username_override == created_by` match in `require_job_read_access` is forgeable. The fix
|
||||
//! binds that fast path to a non-forgeable attribute — the job's `permissioned_as_email` (the
|
||||
//! token owner's email) must equal the caller's email. This:
|
||||
//! - denies a colliding-label token created by a different principal, while
|
||||
//! - still allowing a principal to re-read its own labeled-token jobs (incl. when RLS would
|
||||
//! otherwise hide them), and
|
||||
//! - leaving user-facing webhook/http/email trigger token creation untouched (those labels
|
||||
//! are created through the public token API by design).
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn bearer(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {token}"))
|
||||
}
|
||||
|
||||
async fn create_token_with_label(port: u16, caller_token: &str, label: &str) -> reqwest::Response {
|
||||
bearer(
|
||||
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
|
||||
caller_token,
|
||||
)
|
||||
.json(&json!({ "label": label }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Insert a completed job with a labeled-token `created_by`, running as `permissioned_as`
|
||||
/// (email `permissioned_as_email`) with the given `runnable_path` (which governs RLS).
|
||||
async fn insert_labeled_job(
|
||||
db: &Pool<Postgres>,
|
||||
created_by: &str,
|
||||
runnable_path: &str,
|
||||
permissioned_as: &str,
|
||||
permissioned_as_email: &str,
|
||||
) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, runnable_path, kind, tag, args, visible_to_owner)
|
||||
VALUES ($1, 'test-workspace', $2, $3, $4, $5, 'script', 'deno', '{}'::jsonb, true)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(created_by)
|
||||
.bind(permissioned_as)
|
||||
.bind(permissioned_as_email)
|
||||
.bind(runnable_path)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
|
||||
VALUES ($1, 'test-workspace', 100, '{\"secret\":\"super-secret-value\"}'::jsonb, 'success')",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// The core IDOR: an operator who mints a token whose label collides with another
|
||||
/// principal's labeled-token identity must NOT be able to read that principal's job — the
|
||||
/// `permissioned_as_email` of that job is the victim's, not the attacker's.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_label_collision_does_not_grant_job_read(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
|
||||
// A job submitted with a token labeled "collide", running as the admin (test-user).
|
||||
let job_id = insert_labeled_job(
|
||||
&db,
|
||||
"label-collide",
|
||||
"u/test-user/secret_script",
|
||||
"u/test-user",
|
||||
"test@windmill.dev",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Sanity: the admin can read it, so the job exists and is otherwise readable.
|
||||
let resp = bearer(
|
||||
client().get(format!("{base}/completed/get/{job_id}")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "admin must still read the job");
|
||||
|
||||
// The attacker (a different member, test-user-2) mints a colliding-label token.
|
||||
let resp = create_token_with_label(port, "SECRET_TOKEN_2", "collide").await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let attacker_token = resp.text().await?;
|
||||
|
||||
// Reading the admin's job with the colliding token must be denied. Before the fix the
|
||||
// `username_override == created_by` fast path returned the full result here.
|
||||
let resp = bearer(
|
||||
client().get(format!("{base}/completed/get/{job_id}")),
|
||||
&attacker_token,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"colliding-label token must not read another principal's job (got {})",
|
||||
resp.status()
|
||||
);
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
!body.contains("super-secret-value"),
|
||||
"job result must not leak to the colliding-label token"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The fix must not regress the legitimate case: a principal re-reading its own
|
||||
/// labeled-token job is granted via the email-bound fast path, even when RLS would hide the
|
||||
/// job (the runnable lives in another user's space the caller has no RLS path to).
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_legit_labeled_self_read_still_works(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
|
||||
// Created by test-user-2's labeled token, running as test-user-2, but the runnable lives
|
||||
// under u/test-user so RLS alone would not reveal it to test-user-2 — the grant must come
|
||||
// from the email-bound fast path.
|
||||
let job_id = insert_labeled_job(
|
||||
&db,
|
||||
"label-mine",
|
||||
"u/test-user/shared_script",
|
||||
"u/test-user-2",
|
||||
"test2@windmill.dev",
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = create_token_with_label(port, "SECRET_TOKEN_2", "mine").await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let token = resp.text().await?;
|
||||
|
||||
let resp = bearer(
|
||||
client().get(format!("{base}/completed/get/{job_id}")),
|
||||
&token,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"owner must still read their own labeled-token job via the email-bound fast path"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P1 regression guard: the user-facing token API must keep accepting the labels that the
|
||||
/// webhook / http-route / email trigger panels mint (e.g. `webhook-<user>-<rand>`). The fix
|
||||
/// must not reserve those prefixes.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_trigger_token_labels_still_creatable(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
for label in [
|
||||
"webhook-test-user-2-ab12",
|
||||
"http-test-user-2-cd34",
|
||||
"email-test-user-2-ef56",
|
||||
"my-ci-token",
|
||||
] {
|
||||
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"creating a token with label {label:?} must succeed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -108,12 +108,10 @@ async fn test_variable_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(secret["value"], serde_json::Value::Null);
|
||||
|
||||
// list with path_start filter
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/list?path_start=u/test-user/plain"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{base}/list?path_start=u/test-user/plain")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let list = resp.json::<Vec<serde_json::Value>>().await?;
|
||||
assert_eq!(list.len(), 1);
|
||||
@@ -252,3 +250,91 @@ async fn test_variable_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test: the variable-value cache (`get_value?allow_cache=true`) must be
|
||||
/// identity-scoped. test-user-2 (folder access) warms the cache; test-user-3 (no access)
|
||||
/// must then be denied rather than served the cached value.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "variable_cache_rls"))]
|
||||
async fn test_variable_value_cache_is_identity_scoped(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let url = format!(
|
||||
"{}?allow_cache=true",
|
||||
variable_url(port, "get_value", "f/secret/cache_target_var")
|
||||
);
|
||||
let get = |token: &str| {
|
||||
client()
|
||||
.get(url.as_str())
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
};
|
||||
|
||||
// test-user-2 has folder access and WARMS the cache.
|
||||
let resp = get("SECRET_TOKEN_2").send().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.text().await?.contains("LEAKED_VAR_SECRET"));
|
||||
|
||||
// test-user-3 has no folder access: must miss the cache and be denied (401), not leak.
|
||||
let resp = get("SECRET_TOKEN_3").send().await?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
assert!(!resp.text().await?.contains("LEAKED_VAR_SECRET"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Secret variables ARE cached (with their per-read side effects — the EE
|
||||
/// `variables.decrypt_secret` audit and running-job secret registration — re-run on every
|
||||
/// hit; that re-emission is not observable in the OSS build since `audit_log` is a no-op).
|
||||
/// We assert the caching itself: warm the cache, delete the row directly (no API/NOTIFY, so
|
||||
/// the in-memory cache survives), and re-read with `allow_cache=true` — the value is still
|
||||
/// returned from cache. A non-secret variable behaves identically (control).
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_variables_are_cached(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/variables");
|
||||
|
||||
let plain = "u/test-user/cache_plain_probe";
|
||||
let secret = "u/test-user/cache_secret_probe";
|
||||
|
||||
// Create one non-secret and one secret variable (the secret is stored encrypted).
|
||||
for (path, value, is_secret) in [
|
||||
(plain, "PLAIN_PROBE", false),
|
||||
(secret, "SECRET_PROBE", true),
|
||||
] {
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(
|
||||
&json!({ "path": path, "value": value, "is_secret": is_secret, "description": "" }),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
}
|
||||
|
||||
let read = |path: &str| {
|
||||
let url = format!("{base}/get_value/{path}?allow_cache=true");
|
||||
async move { authed(client().get(url)).send().await.unwrap() }
|
||||
};
|
||||
|
||||
// Warm the cache for both.
|
||||
assert_eq!(read(plain).await.json::<String>().await?, "PLAIN_PROBE");
|
||||
assert_eq!(read(secret).await.json::<String>().await?, "SECRET_PROBE");
|
||||
|
||||
// Delete both rows directly — bypasses the API and its NOTIFY-based invalidation, so
|
||||
// the in-memory cache survives. A subsequent read can only succeed from cache.
|
||||
for path in [plain, secret] {
|
||||
sqlx::query("DELETE FROM variable WHERE workspace_id = 'test-workspace' AND path = $1")
|
||||
.bind(path)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Both (secret included) are still served from the cache.
|
||||
assert_eq!(read(plain).await.json::<String>().await?, "PLAIN_PROBE");
|
||||
let resp = read(secret).await;
|
||||
assert_eq!(resp.status(), 200, "secret must still be served from cache");
|
||||
assert_eq!(resp.json::<String>().await?, "SECRET_PROBE");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -709,7 +709,9 @@ async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
|
||||
"resource_path": "u/test-user/openai_instance",
|
||||
"models": ["gpt-4o-mini"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"default_model": { "provider": "openai", "model": "gpt-4o-mini" },
|
||||
"metadata_model": { "provider": "openai", "model": "gpt-4o-mini" }
|
||||
});
|
||||
let workspace_ai_config = json!({
|
||||
"providers": {
|
||||
@@ -749,6 +751,10 @@ async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
|
||||
settings["instance_ai_summary"]["providers"][0]["models"][0],
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["metadata_model"]["model"],
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
|
||||
.bind(workspace_ai_config)
|
||||
|
||||
@@ -191,6 +191,7 @@ async fn get_concurrent_intervals(
|
||||
script_path_exact: None,
|
||||
script_hash: None,
|
||||
created_by: None,
|
||||
status: None,
|
||||
success: None,
|
||||
running: None,
|
||||
parent_job: None,
|
||||
|
||||
@@ -430,7 +430,15 @@ pub fn filter_list_completed_query(
|
||||
sqlb.and_where_in("created_by", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(r) = &lq.success {
|
||||
if let Some(status) = &lq.status {
|
||||
let status = match status {
|
||||
windmill_common::jobs::JobStatus::Success => "success",
|
||||
windmill_common::jobs::JobStatus::Failure => "failure",
|
||||
windmill_common::jobs::JobStatus::Canceled => "canceled",
|
||||
windmill_common::jobs::JobStatus::Skipped => "skipped",
|
||||
};
|
||||
sqlb.and_where_eq("v2_job_completed.status", quote(status));
|
||||
} else if let Some(r) = &lq.success {
|
||||
if *r {
|
||||
sqlb.and_where_eq("status", "'success'")
|
||||
.or_where_eq("status", "'skipped'");
|
||||
@@ -572,6 +580,11 @@ pub fn list_completed_jobs_query(
|
||||
if lq.completed_before.is_some()
|
||||
|| lq.completed_after.is_some()
|
||||
|| lq.success == Some(false)
|
||||
|| matches!(
|
||||
lq.status,
|
||||
Some(windmill_common::jobs::JobStatus::Failure)
|
||||
| Some(windmill_common::jobs::JobStatus::Canceled)
|
||||
)
|
||||
{
|
||||
"v2_job_completed.completed_at"
|
||||
} else {
|
||||
@@ -653,6 +666,7 @@ mod tests {
|
||||
created_after_queue: None,
|
||||
completed_after: None,
|
||||
completed_before: None,
|
||||
status: None,
|
||||
success: None,
|
||||
running: None,
|
||||
parent_job: None,
|
||||
@@ -928,6 +942,23 @@ mod tests {
|
||||
assert!(sql.contains("'failure'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_status_canceled() {
|
||||
let lq = ListCompletedQuery {
|
||||
status: Some(windmill_common::jobs::JobStatus::Canceled),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
"ws",
|
||||
false,
|
||||
);
|
||||
let sql = build_sql(sqlb);
|
||||
assert!(sql.contains("v2_job_completed.status"));
|
||||
assert!(sql.contains("'canceled'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completed_order_by_completed_at() {
|
||||
let lq = ListCompletedQuery {
|
||||
@@ -939,6 +970,25 @@ mod tests {
|
||||
assert!(sql.contains("completed_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completed_order_by_completed_at_status_failure_canceled() {
|
||||
// status=failure|canceled must order by v2_job_completed.completed_at so the
|
||||
// partial index ix_v2_job_completed_failure_workspace serves both filtering
|
||||
// and ordering in a single scan.
|
||||
for status in [
|
||||
windmill_common::jobs::JobStatus::Failure,
|
||||
windmill_common::jobs::JobStatus::Canceled,
|
||||
] {
|
||||
let lq = ListCompletedQuery { status: Some(status), ..empty_completed_query() };
|
||||
let sqlb = list_completed_jobs_query("ws", Some(10), 0, &lq, &["id"], false, None);
|
||||
let sql = build_sql(sqlb);
|
||||
assert!(
|
||||
sql.contains("ORDER BY v2_job_completed.completed_at"),
|
||||
"expected order by completed_at, got: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_label() {
|
||||
let lq = ListCompletedQuery {
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{
|
||||
error,
|
||||
jobs::{CompletedJob, JobKind, JobTriggerKind, QueuedJob},
|
||||
jobs::{CompletedJob, JobKind, JobStatus, JobTriggerKind, QueuedJob},
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
utils::now_from_db,
|
||||
DB,
|
||||
@@ -142,6 +142,7 @@ pub struct ListCompletedQuery {
|
||||
pub created_after_queue: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub completed_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub completed_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub status: Option<JobStatus>,
|
||||
pub success: Option<bool>,
|
||||
pub running: Option<bool>,
|
||||
pub parent_job: Option<String>,
|
||||
@@ -680,6 +681,7 @@ mod tests {
|
||||
created_after_queue: None,
|
||||
completed_after: None,
|
||||
completed_before: None,
|
||||
status: None,
|
||||
success: None,
|
||||
running: Some(true),
|
||||
parent_job: None,
|
||||
@@ -752,6 +754,7 @@ mod tests {
|
||||
created_after_queue: Some(specific_time),
|
||||
completed_after: None,
|
||||
completed_before: None,
|
||||
status: None,
|
||||
success: None,
|
||||
running: None,
|
||||
parent_job: None,
|
||||
|
||||
@@ -13,6 +13,7 @@ default = []
|
||||
enterprise = ["windmill-common/enterprise"]
|
||||
private = ["windmill-common/private", "windmill-dep-map/private"]
|
||||
python = ["dep:windmill-parser-py"]
|
||||
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-object-store.workspace = true
|
||||
@@ -38,4 +39,5 @@ tracing.workspace = true
|
||||
chrono.workspace = true
|
||||
lazy_static.workspace = true
|
||||
tokio.workspace = true
|
||||
prometheus = { workspace = true, optional = true }
|
||||
windmill-parser-py = { workspace = true, optional = true }
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
use axum::extract::Multipart;
|
||||
use windmill_api_auth::{
|
||||
auth::{list_tokens_internal, AuthCache, TruncatedTokenWithEmail},
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
|
||||
ApiAuthed,
|
||||
};
|
||||
use windmill_common::{
|
||||
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
|
||||
@@ -158,6 +159,7 @@ async fn list_search_scripts(
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let n = 10;
|
||||
|
||||
let allowed = build_scope_path_predicate(&authed, "scripts", "read");
|
||||
let rows = sqlx::query_as!(
|
||||
SearchScript,
|
||||
"SELECT path, content from script WHERE workspace_id = $1 AND archived = false LIMIT $2",
|
||||
@@ -167,6 +169,7 @@ async fn list_search_scripts(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
@@ -323,9 +326,13 @@ async fn list_scripts(
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "scripts", "read");
|
||||
let mut rows = sqlx::query_as::<_, ListableScript>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
|
||||
// Draft-only rows: drafts the authed user has at paths with no
|
||||
@@ -2093,14 +2100,64 @@ async fn raw_script_by_path_unpinned(
|
||||
lazy_static::lazy_static! {
|
||||
static ref DEBUG_RAW_SCRIPT_ENDPOINTS: bool =
|
||||
std::env::var("DEBUG_RAW_SCRIPT_ENDPOINTS").is_ok();
|
||||
|
||||
/// Fallback freshness window (seconds) for [`RAW_SCRIPT_LATEST_HASH_CACHE`].
|
||||
/// Primary invalidation is event-driven: deploying a script writes a
|
||||
/// `notify_runnable_version_change` row, and the server's polling-events handler
|
||||
/// evicts the entry across all replicas (see `main.rs`). This TTL only bounds
|
||||
/// staleness if that event is missed. Defaults to 60s (matches
|
||||
/// `DEPLOYED_SCRIPT_HASH_CACHE`). Override with `RAW_SCRIPT_CACHE_TTL_SECONDS`.
|
||||
static ref RAW_SCRIPT_CACHE_TTL_S: i64 = std::env::var("RAW_SCRIPT_CACHE_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|s| *s >= 0)
|
||||
.unwrap_or(60);
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// Imported-script content, keyed by
|
||||
// `{ws}:{path}:{importer_cache_key}[:unpinned]:{latest_hash}`. Including the
|
||||
// imported script's own latest hash makes each entry immutable, so no
|
||||
// per-entry TTL is needed; staleness is bounded by RAW_SCRIPT_LATEST_HASH_CACHE.
|
||||
pub static ref RAW_SCRIPT_CACHE: Cache<String, String> = Cache::new(1000);
|
||||
// `{ws}:{path}` (bare path) -> (latest non-archived hash, unix_ts cached).
|
||||
// Resolving the imported script's own hash and keying content by it is what
|
||||
// fixes relative-import staleness for deployed scripts, whose importer hash
|
||||
// never moves (see #6769). Evicted on deploy by the `notify_runnable_version_change`
|
||||
// handler in main.rs (cross-replica, within a poll interval); RAW_SCRIPT_CACHE_TTL_S
|
||||
// is a fallback bound.
|
||||
pub static ref RAW_SCRIPT_LATEST_HASH_CACHE: Cache<String, (i64, i64)> = Cache::new(1000);
|
||||
pub static ref CACHE_FOLDERS_PATH: Cache<String, i64> = Cache::new(1000);
|
||||
|
||||
}
|
||||
|
||||
/// Records a [`RAW_SCRIPT_CACHE`] lookup outcome (`hit` / `expired` / `miss`) to
|
||||
/// the `raw_script_cache_total` counter when the prometheus feature is enabled.
|
||||
#[cfg(feature = "prometheus")]
|
||||
fn record_raw_script_cache(result: &str) {
|
||||
if let Some(c) = RAW_SCRIPT_CACHE_METRIC.as_ref() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "prometheus"))]
|
||||
fn record_raw_script_cache(_result: &str) {}
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
lazy_static::lazy_static! {
|
||||
/// Raw relative-import cache lookups, labeled by `result` (hit/expired/miss).
|
||||
static ref RAW_SCRIPT_CACHE_METRIC: Option<prometheus::IntCounterVec> =
|
||||
if windmill_common::METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
Some(prometheus::register_int_counter_vec!(
|
||||
"raw_script_cache_total",
|
||||
"Raw script relative-import cache lookups by result (hit/expired/miss)",
|
||||
&["result"]
|
||||
).unwrap())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
async fn raw_script_by_path_internal(
|
||||
path: StripPath,
|
||||
user_db: UserDB,
|
||||
@@ -2122,23 +2179,10 @@ async fn raw_script_by_path_internal(
|
||||
}
|
||||
}
|
||||
|
||||
let cache_path = query
|
||||
.cache_key
|
||||
.map(|x| format!("{w_id}:{path}:{x}{}", if unpin { ":unpinned" } else { "" }));
|
||||
if let Some(cache_path) = cache_path.clone() {
|
||||
let cached_content = RAW_SCRIPT_CACHE.get(&cache_path);
|
||||
if let Some(cached_content) = cached_content {
|
||||
if *DEBUG_RAW_SCRIPT_ENDPOINTS {
|
||||
tracing::warn!("Raw script by path request: {} (cached)", path);
|
||||
}
|
||||
return Ok(cached_content);
|
||||
}
|
||||
}
|
||||
|
||||
if *DEBUG_RAW_SCRIPT_ENDPOINTS {
|
||||
tracing::warn!("Raw script by path request: {} (not cached)", path);
|
||||
}
|
||||
|
||||
// Validate + strip the language extension up front so cache keys use the bare
|
||||
// script path. This matches the `notify_runnable_version_change` event payload
|
||||
// (which carries the bare path), so a deploy can evict RAW_SCRIPT_LATEST_HASH_CACHE
|
||||
// by key from the polling-events handler in the server binary.
|
||||
if !path.ends_with(".py")
|
||||
&& !path.ends_with(".ts")
|
||||
&& !path.ends_with(".go")
|
||||
@@ -2157,6 +2201,52 @@ async fn raw_script_by_path_internal(
|
||||
.trim_end_matches(".go")
|
||||
.trim_end_matches(".sh");
|
||||
|
||||
// Content cache is keyed by the IMPORTED script's own latest hash, not by the
|
||||
// importer's runnable hash (`query.cache_key`). The importer hash never moves
|
||||
// when only an imported script's content changes (relock is in-place — see
|
||||
// #6769), so keying solely on it served stale content indefinitely. The
|
||||
// importer + unpin dimensions are kept to preserve per-runnable authorization
|
||||
// scoping (a content-cache hit skips the authed RLS query, so an entry must
|
||||
// stay scoped to the runnable that fetched it); the imported latest hash is
|
||||
// appended for content correctness.
|
||||
let cache_path_base = query
|
||||
.cache_key
|
||||
.as_ref()
|
||||
.map(|x| format!("{w_id}:{path}:{x}{}", if unpin { ":unpinned" } else { "" }));
|
||||
|
||||
// Resolve the imported script's latest hash from RAW_SCRIPT_LATEST_HASH_CACHE
|
||||
// (keyed by the bare path so the deploy event can evict it). A fresh entry
|
||||
// serves from the immutable content cache with no DB hit; a stale/absent entry
|
||||
// falls through to the query below, which refreshes both caches.
|
||||
let hash_cache_key = format!("{w_id}:{path}");
|
||||
let (fresh_hash, had_stale_hash) = match RAW_SCRIPT_LATEST_HASH_CACHE.get(&hash_cache_key) {
|
||||
Some((hash, cached_at))
|
||||
if chrono::Utc::now().timestamp() - cached_at <= *RAW_SCRIPT_CACHE_TTL_S =>
|
||||
{
|
||||
(Some(hash), false)
|
||||
}
|
||||
Some(_) => (None, true),
|
||||
None => (None, false),
|
||||
};
|
||||
|
||||
if let (Some(base), Some(latest_hash)) = (cache_path_base.as_ref(), fresh_hash) {
|
||||
let content_key = format!("{base}:{latest_hash}");
|
||||
if let Some(cached_content) = RAW_SCRIPT_CACHE.get(&content_key) {
|
||||
if *DEBUG_RAW_SCRIPT_ENDPOINTS {
|
||||
tracing::warn!("Raw script by path request: {path} (cached, key={content_key})");
|
||||
}
|
||||
record_raw_script_cache("hit");
|
||||
return Ok(cached_content);
|
||||
}
|
||||
}
|
||||
if cache_path_base.is_some() {
|
||||
record_raw_script_cache(if had_stale_hash { "expired" } else { "miss" });
|
||||
}
|
||||
|
||||
if *DEBUG_RAW_SCRIPT_ENDPOINTS {
|
||||
tracing::warn!("Raw script by path request: {} (not cached)", path);
|
||||
}
|
||||
|
||||
// folder cache is only useful for python given it needs to recuse over all intermediate folders to find the package.
|
||||
// When a script exists in a folder, we can cache the fact that the folder exists to avoid extra db calls.
|
||||
let mut split_path = path.split("/").collect::<Vec<&str>>();
|
||||
@@ -2189,8 +2279,10 @@ async fn raw_script_by_path_internal(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let content_o = sqlx::query_scalar!(
|
||||
"SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
// Fetch the latest non-archived row's hash AND content in one query: the hash
|
||||
// keys the (immutable) content cache and refreshes RAW_SCRIPT_LATEST_HASH_CACHE.
|
||||
let row_o = sqlx::query!(
|
||||
"SELECT hash, content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
@@ -2198,6 +2290,10 @@ async fn raw_script_by_path_internal(
|
||||
.warn_after_seconds(5)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let (db_hash, content_o) = match row_o {
|
||||
Some(r) => (Some(r.hash), Some(r.content)),
|
||||
None => (None, None),
|
||||
};
|
||||
if *DEBUG_RAW_SCRIPT_ENDPOINTS {
|
||||
tracing::warn!(
|
||||
"Raw script by path request: {} (content: {:?})",
|
||||
@@ -2261,8 +2357,14 @@ async fn raw_script_by_path_internal(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cache_path) = cache_path {
|
||||
RAW_SCRIPT_CACHE.insert(cache_path, content.clone());
|
||||
// content_o was Some, so db_hash is Some too (same row). Refresh the latest-hash
|
||||
// cache and store the content under the hash-qualified key.
|
||||
if let Some(db_hash) = db_hash {
|
||||
RAW_SCRIPT_LATEST_HASH_CACHE
|
||||
.insert(hash_cache_key, (db_hash, chrono::Utc::now().timestamp()));
|
||||
if let Some(base) = cache_path_base {
|
||||
RAW_SCRIPT_CACHE.insert(format!("{base}:{db_hash}"), content.clone());
|
||||
}
|
||||
}
|
||||
if *DEBUG_RAW_SCRIPT_ENDPOINTS {
|
||||
tracing::warn!("Raw script by path request: {} (content response)", path);
|
||||
|
||||
@@ -340,6 +340,8 @@ pub struct InstanceAISummary {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<InstanceAIModelSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata_model: Option<InstanceAIModelSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_completion_model: Option<InstanceAIModelSummary>,
|
||||
}
|
||||
|
||||
@@ -825,6 +827,7 @@ pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<I
|
||||
Some(InstanceAISummary {
|
||||
providers: provider_summaries,
|
||||
default_model: extract_instance_ai_model_summary(config, "default_model"),
|
||||
metadata_model: extract_instance_ai_model_summary(config, "metadata_model"),
|
||||
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ enterprise_saml = ["dep:samael", "dep:libxml"]
|
||||
benchmark = []
|
||||
embedding = ["windmill-api-embeddings/embedding"]
|
||||
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
|
||||
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"]
|
||||
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus", "windmill-api-scripts/prometheus"]
|
||||
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
|
||||
tantivy = ["dep:windmill-indexer"]
|
||||
kafka = ["dep:windmill-trigger-kafka", "windmill-store/kafka"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"version": "1.708.0",
|
||||
"version": "1.713.1",
|
||||
"title": "Windmill API",
|
||||
"contact": {
|
||||
"name": "Windmill Team",
|
||||
@@ -15229,6 +15229,10 @@
|
||||
},
|
||||
"deployment_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15295,6 +15299,10 @@
|
||||
"properties": {
|
||||
"deployment_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16069,6 +16077,10 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -16146,6 +16158,10 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -16679,6 +16695,10 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16750,6 +16770,10 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -28210,6 +28234,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workers/workspace_fairness_events": {
|
||||
"get": {
|
||||
"summary": "list last 100 workspace-fairness cap/uncap events (cloud-only)",
|
||||
"operationId": "getWorkspaceFairnessEvents",
|
||||
"tags": [
|
||||
"worker"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "workspace fairness events (empty on non-cloud)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"nullable": true,
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"timestamp",
|
||||
"operation"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/configs/list_worker_groups": {
|
||||
"get": {
|
||||
"summary": "list worker groups",
|
||||
@@ -33130,6 +33200,10 @@
|
||||
"type": "boolean",
|
||||
"description": "If true, all steps run on the same worker for better performance"
|
||||
},
|
||||
"preserve_step_tags": {
|
||||
"type": "boolean",
|
||||
"description": "If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."
|
||||
},
|
||||
"concurrent_limit": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of concurrent executions of this flow"
|
||||
@@ -35312,6 +35386,9 @@
|
||||
"default_model": {
|
||||
"$ref": "#/components/schemas/AIProviderModel"
|
||||
},
|
||||
"metadata_model": {
|
||||
"$ref": "#/components/schemas/AIProviderModel"
|
||||
},
|
||||
"code_completion_model": {
|
||||
"$ref": "#/components/schemas/AIProviderModel"
|
||||
},
|
||||
@@ -35361,6 +35438,9 @@
|
||||
"default_model": {
|
||||
"$ref": "#/components/schemas/AIProviderModel"
|
||||
},
|
||||
"metadata_model": {
|
||||
"$ref": "#/components/schemas/AIProviderModel"
|
||||
},
|
||||
"code_completion_model": {
|
||||
"$ref": "#/components/schemas/AIProviderModel"
|
||||
}
|
||||
@@ -35837,6 +35917,10 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"skip_draft_deletion": {
|
||||
"type": "boolean",
|
||||
"description": "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -40538,6 +40622,13 @@
|
||||
},
|
||||
"tag": {
|
||||
"$ref": "#/components/schemas/CustomInstanceDbTag"
|
||||
},
|
||||
"used_by_workspaces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -46176,6 +46267,10 @@
|
||||
"type": "boolean",
|
||||
"description": "If true, all steps run on the same worker for better performance"
|
||||
},
|
||||
"preserve_step_tags": {
|
||||
"type": "boolean",
|
||||
"description": "If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."
|
||||
},
|
||||
"concurrent_limit": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of concurrent executions of this flow"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
version: 1.708.0
|
||||
version: 1.713.1
|
||||
title: Windmill API
|
||||
contact:
|
||||
name: Windmill Team
|
||||
@@ -2057,6 +2057,14 @@ paths:
|
||||
enum: &ref_22
|
||||
- ducklake
|
||||
- datatable
|
||||
used_by_workspaces:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >-
|
||||
Workspaces that reference this database via a ducklake
|
||||
catalog or datatable database with resource_type
|
||||
'instance'. Computed at request time, not persisted.
|
||||
/settings/setup_custom_instance_pg_database/{name}:
|
||||
post:
|
||||
summary: >-
|
||||
@@ -4842,6 +4850,10 @@ paths:
|
||||
required: &ref_44
|
||||
- model
|
||||
- provider
|
||||
metadata_model:
|
||||
type: object
|
||||
properties: *ref_43
|
||||
required: *ref_44
|
||||
code_completion_model:
|
||||
type: object
|
||||
properties: *ref_43
|
||||
@@ -5828,6 +5840,10 @@ paths:
|
||||
type: object
|
||||
properties: *ref_43
|
||||
required: *ref_44
|
||||
metadata_model:
|
||||
type: object
|
||||
properties: *ref_43
|
||||
required: *ref_44
|
||||
code_completion_model:
|
||||
type: object
|
||||
properties: *ref_43
|
||||
@@ -11364,6 +11380,13 @@ paths:
|
||||
description: >-
|
||||
If true, all steps run on the same worker for
|
||||
better performance
|
||||
preserve_step_tags:
|
||||
type: boolean
|
||||
description: >-
|
||||
If true and the flow runs on a custom worker tag,
|
||||
steps that declare their own non-empty tag run on
|
||||
it instead of inheriting the flow tag. Steps
|
||||
without their own tag still inherit the flow tag.
|
||||
concurrent_limit:
|
||||
type: number
|
||||
description: >-
|
||||
@@ -12619,6 +12642,11 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this script
|
||||
does not delete an existing user draft at the same path.
|
||||
required: &ref_105
|
||||
- path
|
||||
- summary
|
||||
@@ -15461,6 +15489,12 @@ paths:
|
||||
type: boolean
|
||||
deployment_message:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this
|
||||
flow does not delete an existing user draft at the same
|
||||
path.
|
||||
responses:
|
||||
'201':
|
||||
description: flow created
|
||||
@@ -15507,6 +15541,12 @@ paths:
|
||||
properties:
|
||||
deployment_message:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this
|
||||
flow does not delete an existing user draft at the same
|
||||
path.
|
||||
responses:
|
||||
'200':
|
||||
description: flow updated
|
||||
@@ -16244,6 +16284,11 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this app
|
||||
does not delete an existing user draft at the same path.
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -16303,6 +16348,12 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this
|
||||
app does not delete an existing user draft at the same
|
||||
path.
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -16740,6 +16791,11 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this app
|
||||
does not delete an existing user draft at the same path.
|
||||
responses:
|
||||
'200':
|
||||
description: app updated
|
||||
@@ -16796,6 +16852,12 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true (set by the CLI / git sync), deploying this
|
||||
app does not delete an existing user draft at the same
|
||||
path.
|
||||
js:
|
||||
type: string
|
||||
css:
|
||||
@@ -17954,6 +18016,13 @@ paths:
|
||||
description: >-
|
||||
If true, all steps run on the same worker for better
|
||||
performance
|
||||
preserve_step_tags:
|
||||
type: boolean
|
||||
description: >-
|
||||
If true and the flow runs on a custom worker tag, steps
|
||||
that declare their own non-empty tag run on it instead
|
||||
of inheriting the flow tag. Steps without their own tag
|
||||
still inherit the flow tag.
|
||||
concurrent_limit:
|
||||
type: number
|
||||
description: Maximum number of concurrent executions of this flow
|
||||
@@ -30256,6 +30325,37 @@ paths:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
/workers/workspace_fairness_events:
|
||||
get:
|
||||
summary: list last 100 workspace-fairness cap/uncap events (cloud-only)
|
||||
operationId: getWorkspaceFairnessEvents
|
||||
tags:
|
||||
- worker
|
||||
responses:
|
||||
'200':
|
||||
description: workspace fairness events (empty on non-cloud)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
operation:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
nullable: true
|
||||
parameters:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
required:
|
||||
- timestamp
|
||||
- operation
|
||||
/configs/list_worker_groups:
|
||||
get:
|
||||
summary: list worker groups
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.713.1
|
||||
version: 1.719.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -9442,6 +9442,33 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/jobs/job_view_token/{id}:
|
||||
get:
|
||||
summary: mint a read-only share token for a job
|
||||
description: >
|
||||
Returns a stateless `{job_id}.{hmac}` token that grants an authenticated
|
||||
workspace member read access to this job (and its flow subtree) via a
|
||||
`view_token` query param or `X-View-Token` header. Only callable by a user
|
||||
who can already read the job.
|
||||
operationId: getJobViewToken
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: the share read token
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/list_paths:
|
||||
get:
|
||||
summary: list all flow paths
|
||||
@@ -11903,6 +11930,16 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: status
|
||||
description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- failure
|
||||
- canceled
|
||||
- skipped
|
||||
- name: all_workspaces
|
||||
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
|
||||
in: query
|
||||
@@ -12138,6 +12175,16 @@ paths:
|
||||
- $ref: "#/components/parameters/StartedBefore"
|
||||
- $ref: "#/components/parameters/StartedAfter"
|
||||
- $ref: "#/components/parameters/Success"
|
||||
- name: status
|
||||
description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- failure
|
||||
- canceled
|
||||
- skipped
|
||||
- $ref: "#/components/parameters/JobKinds"
|
||||
- $ref: "#/components/parameters/ArgsFilter"
|
||||
- $ref: "#/components/parameters/ResultFilter"
|
||||
@@ -12345,6 +12392,16 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: status
|
||||
description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- failure
|
||||
- canceled
|
||||
- skipped
|
||||
- name: all_workspaces
|
||||
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
|
||||
in: query
|
||||
@@ -19887,6 +19944,16 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: status
|
||||
description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- failure
|
||||
- canceled
|
||||
- skipped
|
||||
- name: all_workspaces
|
||||
description: get jobs from all workspaces (only valid if request come from the `admins` workspace)
|
||||
in: query
|
||||
@@ -21748,6 +21815,8 @@ components:
|
||||
$ref: "#/components/schemas/AIProviderConfig"
|
||||
default_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
metadata_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
code_completion_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
custom_prompts:
|
||||
@@ -21783,6 +21852,8 @@ components:
|
||||
$ref: "#/components/schemas/InstanceAIProviderSummary"
|
||||
default_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
metadata_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
code_completion_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
required:
|
||||
|
||||
@@ -377,6 +377,8 @@ pub struct AIConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<ProviderModel>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata_model: Option<ProviderModel>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_completion_model: Option<ProviderModel>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom_prompts: Option<HashMap<String, String>>,
|
||||
|
||||
@@ -13,9 +13,10 @@ use serde_json::value::RawValue;
|
||||
use sqlx::types::JsonRawValue;
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
jobs::WM_TRACEPARENT,
|
||||
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
|
||||
worker::to_raw_value,
|
||||
DB,
|
||||
DB, OTEL_TRACING_ENABLED,
|
||||
};
|
||||
use windmill_queue::PushArgsOwned;
|
||||
|
||||
@@ -280,6 +281,13 @@ impl WebhookArgs {
|
||||
self,
|
||||
runnable_format: RunnableFormat,
|
||||
) -> Result<PushArgsOwned, Error> {
|
||||
// Capture the inbound W3C `traceparent` before `self.metadata` is
|
||||
// consumed below. Read back at root-job completion to link the job's
|
||||
// OTLP span to the originating distributed trace. Deliberately bypasses
|
||||
// the header whitelist, and is gated to OTel-enabled instances so others
|
||||
// don't get a stray `_wm_traceparent` arg key.
|
||||
let trace_context = inbound_traceparent(&self.metadata.headers);
|
||||
|
||||
let headers = build_headers(
|
||||
&self.metadata.headers,
|
||||
self.metadata.query_include_header,
|
||||
@@ -292,7 +300,7 @@ impl WebhookArgs {
|
||||
runnable_format.has_preprocessor,
|
||||
);
|
||||
|
||||
match runnable_format {
|
||||
let mut push_args = match runnable_format {
|
||||
RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => {
|
||||
let mut args = HashMap::new();
|
||||
|
||||
@@ -307,7 +315,7 @@ impl WebhookArgs {
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(PushArgsOwned { args, extra: None })
|
||||
PushArgsOwned { args, extra: None }
|
||||
}
|
||||
RunnableFormat { has_preprocessor, .. } => {
|
||||
let mut extra = HashMap::new();
|
||||
@@ -343,16 +351,40 @@ impl WebhookArgs {
|
||||
if query_wrap_body {
|
||||
body = HashMap::from([("body".to_string(), to_raw_value(&body))]);
|
||||
}
|
||||
Ok(PushArgsOwned { args: body, extra })
|
||||
PushArgsOwned { args: body, extra }
|
||||
}
|
||||
Body::NoHashMap(args) => {
|
||||
let mut hm = HashMap::new();
|
||||
hm.insert("body".to_string(), args);
|
||||
Ok(PushArgsOwned { args: hm, extra })
|
||||
PushArgsOwned { args: hm, extra }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// `_wm_traceparent` is Windmill-controlled: strip any caller-supplied
|
||||
// value (e.g. smuggled through the request body) so only the header we
|
||||
// captured above can become the job's inbound trace context. Then stash
|
||||
// the captured value as a reserved arg key — it rides the `args` jsonb
|
||||
// like `_ENTRYPOINT_OVERRIDE`; normal scripts never see it (args are
|
||||
// bound by declared parameter name).
|
||||
push_args.args.remove(WM_TRACEPARENT);
|
||||
if let Some(ref mut extra) = push_args.extra {
|
||||
extra.remove(WM_TRACEPARENT);
|
||||
}
|
||||
if let Some(trace_context) = trace_context {
|
||||
let raw = to_raw_value(&trace_context);
|
||||
match push_args.extra {
|
||||
Some(ref mut extra) => {
|
||||
extra.insert(WM_TRACEPARENT.to_string(), raw);
|
||||
}
|
||||
None => {
|
||||
push_args.args.insert(WM_TRACEPARENT.to_string(), raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(push_args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +519,23 @@ lazy_static::lazy_static! {
|
||||
.collect()).unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Extract the inbound W3C `traceparent` header so the enqueued job can be
|
||||
/// linked back to the originating distributed trace. Returns `None` when OTel
|
||||
/// tracing is disabled (so non-tracing instances don't accumulate a stray
|
||||
/// reserved arg key) or when no `traceparent` header is present. The W3C format
|
||||
/// is not validated here — it is checked later at use time
|
||||
/// (`valid_w3c_traceparent` for the env, EE `span_cx_from_traceparent` for the
|
||||
/// span).
|
||||
fn inbound_traceparent(headers: &HeaderMap) -> Option<String> {
|
||||
if !OTEL_TRACING_ENABLED.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
headers
|
||||
.get("traceparent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub fn build_headers(
|
||||
headers: &HeaderMap,
|
||||
include_header: Option<String>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,11 @@ use axum::{
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed};
|
||||
use windmill_common::{
|
||||
db::{UserDB, DB},
|
||||
db::{DbWithOptAuthed, UserDB, DB},
|
||||
error::{Error, JsonResult, Result},
|
||||
utils::{not_found_if_none, StripPath},
|
||||
};
|
||||
use windmill_store::resources::explain_resource_perm_error;
|
||||
use windmill_store::{resources::explain_resource_perm_error, variables::get_value_internal};
|
||||
|
||||
pub(crate) async fn get_mcp_tools(
|
||||
authed: ApiAuthed,
|
||||
@@ -65,7 +65,7 @@ pub(crate) async fn get_mcp_tools(
|
||||
|
||||
if let Some(info) = token_info {
|
||||
if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) {
|
||||
let refresh_tx = user_db.begin(&authed).await?;
|
||||
let refresh_tx = user_db.clone().begin(&authed).await?;
|
||||
if let Err(e) = crate::oauth2_oss::_refresh_token(
|
||||
refresh_tx,
|
||||
token_var_path,
|
||||
@@ -85,7 +85,23 @@ pub(crate) async fn get_mcp_tools(
|
||||
}
|
||||
}
|
||||
|
||||
let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id)
|
||||
// Resolve the token through the caller's permissioned (RLS + audit) path so
|
||||
// a developer cannot exfiltrate a secret they are not allowed to read by
|
||||
// pointing an MCP resource's token at it.
|
||||
let token = if let Some(token_path) = &mcp_resource.token {
|
||||
let token_var_path = token_path.trim_start_matches("$var:");
|
||||
if token_var_path.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
let db_authed =
|
||||
DbWithOptAuthed::from_authed(&authed, db.clone(), Some(user_db.clone()));
|
||||
Some(get_value_internal(&db_authed, &w_id, token_var_path, false).await?)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let client = windmill_mcp::McpClient::from_resource(mcp_resource, token)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))?;
|
||||
|
||||
|
||||
@@ -3,15 +3,17 @@ use axum::{
|
||||
Extension,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use hmac::{Hmac, Mac};
|
||||
use http::HeaderMap;
|
||||
use hyper::StatusCode;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use sqlx::types::Uuid;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::variables::get_secret_value_as_admin;
|
||||
use windmill_common::error::{to_anyhow, Error};
|
||||
use windmill_common::variables::{get_secret_value_as_admin, get_workspace_key};
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::jobs::{QueryApprover, ResumeUrls};
|
||||
@@ -111,6 +113,9 @@ struct ModalActionValue {
|
||||
dynamic_enums_json: Option<String>,
|
||||
resume_button_text: Option<String>,
|
||||
cancel_button_text: Option<String>,
|
||||
// HMAC over (w_id, job_id, path) keyed on the workspace key; minted by
|
||||
// `send_slack_message`, required by the OpenModal callback branch.
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -119,8 +124,16 @@ struct PrivateMetadata {
|
||||
resource_path: String,
|
||||
container: Container,
|
||||
hide_cancel: Option<bool>,
|
||||
// HMAC over (w_id, resource_path) keyed on the workspace key; minted when the modal is
|
||||
// built, required by `handle_submission` before the resource_path is decrypted.
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify
|
||||
// the Slack request signature (which also defeats replay). It is NOT the primary defense:
|
||||
// the secret is unset in the default deployment, so authorization of the sensitive actions
|
||||
// is instead anchored on a per-workspace HMAC over the callback payload itself (see
|
||||
// `verify_slack_payload`), which holds even when this check is a no-op.
|
||||
#[cfg(feature = "oauth2")]
|
||||
fn verify_slack_callback_signature(headers: &HeaderMap, body: &str) -> Result<(), Error> {
|
||||
if let Some(sv) = crate::SLACK_SIGNING_SECRET.as_ref() {
|
||||
@@ -143,6 +156,66 @@ fn verify_slack_callback_signature(_headers: &HeaderMap, _body: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HMAC keyed on the per-workspace encryption key (the same trust anchor as resume-URL
|
||||
/// signatures). Used to authenticate the `/api/slack` callback payload itself so the
|
||||
/// unauthenticated route cannot be driven into decrypting arbitrary workspace variables,
|
||||
/// regardless of whether `SLACK_SIGNING_SECRET` is configured.
|
||||
type SlackPayloadHmac = Hmac<Sha256>;
|
||||
|
||||
/// Domain-separation tag prepended to every Slack-payload MAC. The workspace key is also used
|
||||
/// for resume-secret signatures (`create_signature` in `jobs.rs`), and those secrets are
|
||||
/// distributed to approvers in resume URLs — so a fixed, scheme-specific prefix makes the two
|
||||
/// MAC families non-interchangeable by construction rather than relying on their byte layouts
|
||||
/// happening to differ. Bump the version suffix if the signed layout ever changes.
|
||||
const SLACK_PAYLOAD_HMAC_DOMAIN: &[u8] = b"slack_payload_v1\0";
|
||||
|
||||
/// Sign the security-sensitive fields of a Slack callback payload with the workspace key.
|
||||
/// Parts are joined with a `\0` delimiter (absent from paths/UUIDs) so distinct field tuples
|
||||
/// cannot collide into the same MAC.
|
||||
async fn sign_slack_payload(db: &DB, w_id: &str, parts: &[&[u8]]) -> Result<String, Error> {
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let mut mac = SlackPayloadHmac::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
|
||||
mac.update(SLACK_PAYLOAD_HMAC_DOMAIN);
|
||||
mac.update(w_id.as_bytes());
|
||||
for part in parts {
|
||||
mac.update(b"\0");
|
||||
mac.update(part);
|
||||
}
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Verify a signature produced by [`sign_slack_payload`] in constant time. A missing or
|
||||
/// malformed signature is rejected: an attacker cannot forge one without the workspace key.
|
||||
async fn verify_slack_payload(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
parts: &[&[u8]],
|
||||
signature: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let signature = signature.ok_or_else(|| {
|
||||
Error::NotAuthorized("Slack callback rejected: missing payload signature".to_string())
|
||||
})?;
|
||||
let provided = hex::decode(signature).map_err(|_| {
|
||||
Error::NotAuthorized("Slack callback rejected: malformed payload signature".to_string())
|
||||
})?;
|
||||
// Map a missing workspace key (e.g. non-existent workspace) to the same generic 401 as a
|
||||
// bad signature, so an unauthenticated caller cannot use the status code (500 vs 401) as a
|
||||
// workspace-existence oracle.
|
||||
let key = get_workspace_key(w_id, db).await.map_err(|_| {
|
||||
Error::NotAuthorized("Slack callback rejected: invalid payload signature".to_string())
|
||||
})?;
|
||||
let mut mac = SlackPayloadHmac::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
|
||||
mac.update(SLACK_PAYLOAD_HMAC_DOMAIN);
|
||||
mac.update(w_id.as_bytes());
|
||||
for part in parts {
|
||||
mac.update(b"\0");
|
||||
mac.update(part);
|
||||
}
|
||||
mac.verify_slice(&provided).map_err(|_| {
|
||||
Error::NotAuthorized("Slack callback rejected: invalid payload signature".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn slack_app_callback_handler(
|
||||
authed: Option<ApiAuthed>,
|
||||
opt_tokened: OptTokened,
|
||||
@@ -188,7 +261,30 @@ pub async fn slack_app_callback_handler(
|
||||
let job_id = Uuid::parse_str(&parsed_value.job_id)?;
|
||||
let flow_step_id = parsed_value.flow_step_id.as_deref();
|
||||
|
||||
let slack_token = get_slack_token(&db, path, w_id).await?;
|
||||
// Authorize the request before any privileged read: the button
|
||||
// payload was minted by `send_slack_message` with an HMAC over
|
||||
// (w_id, job_id, path) keyed on the workspace key. Without a valid
|
||||
// signature an unauthenticated caller cannot reach the decryption
|
||||
// below for an arbitrary variable, even when SLACK_SIGNING_SECRET
|
||||
// is unset.
|
||||
verify_slack_payload(
|
||||
&db,
|
||||
w_id,
|
||||
&[parsed_value.job_id.as_bytes(), path.as_bytes()],
|
||||
parsed_value.signature.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Map any lookup/decryption failure to a generic error: the
|
||||
// raw error echoes the probed `path`/`w_id` back, which would be
|
||||
// a cross-workspace existence oracle. Log the detail server-side.
|
||||
let slack_token =
|
||||
get_slack_token(&db, path, w_id).await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
"Failed to resolve slack token for {w_id}/{path}: {e:#}"
|
||||
);
|
||||
Error::BadRequest("Invalid Slack callback request".to_string())
|
||||
})?;
|
||||
let client = Client::new();
|
||||
let container = payload.container.ok_or_else(|| {
|
||||
Error::BadRequest("No container found.".to_string())
|
||||
@@ -281,6 +377,7 @@ pub async fn request_slack_approval(
|
||||
|
||||
send_slack_message(
|
||||
&client,
|
||||
&db,
|
||||
slack_token.as_str(),
|
||||
channel_id.as_str(),
|
||||
&w_id,
|
||||
@@ -334,12 +431,21 @@ async fn handle_submission(
|
||||
let resource_path = private_metadata.resource_path;
|
||||
let container: Container = private_metadata.container;
|
||||
let hide_cancel = private_metadata.hide_cancel;
|
||||
let signature = private_metadata.signature;
|
||||
|
||||
// If hide_cancel is true, we don't need to extract information from the private_metadata
|
||||
if hide_cancel.unwrap_or(false) && action == "cancel" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let w_id = extract_w_id_from_resume_url(&resume_url)?;
|
||||
// Authorize the submission BEFORE taking any action. `resource_path` comes from the
|
||||
// (client-held) modal metadata and is not covered by the resume-URL signature, so a
|
||||
// tampered/unsigned submission must be rejected up front — otherwise it could still drive
|
||||
// the resume/cancel and reach the decryption below with a swapped path. Require the
|
||||
// workspace-keyed HMAC minted when the modal was built.
|
||||
verify_slack_payload(&db, w_id, &[resource_path.as_bytes()], signature.as_deref()).await?;
|
||||
|
||||
// Use the common handler to process the resume/cancel action
|
||||
handle_resume_action(
|
||||
authed,
|
||||
@@ -351,8 +457,12 @@ async fn handle_submission(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let w_id = extract_w_id_from_resume_url(&resume_url)?;
|
||||
let slack_token = get_slack_token(&db, &resource_path, w_id).await?;
|
||||
let slack_token = get_slack_token(&db, &resource_path, w_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Failed to resolve slack token for {w_id}/{resource_path}: {e:#}");
|
||||
Error::BadRequest("Invalid Slack callback request".to_string())
|
||||
})?;
|
||||
update_original_slack_message(action, slack_token, container).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -780,6 +890,7 @@ async fn get_slack_token(db: &DB, slack_resource_path: &str, w_id: &str) -> anyh
|
||||
// Sends a Slack message with a button that opens a modal
|
||||
async fn send_slack_message(
|
||||
client: &Client,
|
||||
db: &DB,
|
||||
bot_token: &str,
|
||||
channel_id: &str,
|
||||
w_id: &str,
|
||||
@@ -827,6 +938,18 @@ async fn send_slack_message(
|
||||
value["cancel_button_text"] = serde_json::json!(cancel_button_text);
|
||||
}
|
||||
|
||||
// Authenticate the button payload so the unauthenticated callback cannot be driven into
|
||||
// decrypting an arbitrary variable: bind (w_id, job_id, path) with the workspace key.
|
||||
// `job_id` is signed over its string form to match how it is parsed back on callback.
|
||||
let signature = sign_slack_payload(
|
||||
db,
|
||||
w_id,
|
||||
&[job_id.to_string().as_bytes(), resource_path.as_bytes()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
value["signature"] = serde_json::json!(signature);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": "A flow has been suspended. Please approve or reject the flow.",
|
||||
@@ -893,6 +1016,12 @@ async fn get_modal_blocks(
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
) -> Result<axum::Json<serde_json::Value>, Error> {
|
||||
// Bind the resource_path embedded in the modal's private_metadata to the workspace key so
|
||||
// it cannot be tampered with on the way back in `handle_submission`. Computed before `db`
|
||||
// is moved into `get_approval_form_details`.
|
||||
let private_metadata_signature =
|
||||
sign_slack_payload(&db, w_id, &[resource_path.as_bytes()]).await?;
|
||||
|
||||
let approval_details = crate::approvals::get_approval_form_details(
|
||||
db,
|
||||
w_id,
|
||||
@@ -947,6 +1076,7 @@ async fn get_modal_blocks(
|
||||
container,
|
||||
resume_button_text,
|
||||
cancel_button_text,
|
||||
&private_metadata_signature,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -959,6 +1089,7 @@ fn construct_payload(
|
||||
container: Container,
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
signature: &str,
|
||||
) -> serde_json::Value {
|
||||
let mut view = serde_json::json!({
|
||||
"type": "modal",
|
||||
@@ -973,7 +1104,7 @@ fn construct_payload(
|
||||
"type": "plain_text",
|
||||
"text": resume_button_text.unwrap_or("Resume Workflow")
|
||||
},
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel }).to_string(),
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature }).to_string(),
|
||||
});
|
||||
|
||||
if !hide_cancel {
|
||||
|
||||
@@ -484,13 +484,13 @@ async fn update_username_in_workpsace<'c>(
|
||||
).execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
old_username,
|
||||
w_id
|
||||
).execute(&mut **tx)
|
||||
.await?;
|
||||
// NB: workspace_runnable_dependencies.app_path is intentionally NOT rewritten here.
|
||||
// Its FK to app(path, workspace_id) is ON UPDATE CASCADE, so the `UPDATE app SET path`
|
||||
// below propagates the new path automatically. Rewriting it manually here (before the
|
||||
// app row is renamed) points the row at a not-yet-existing app path and violates
|
||||
// fk_workspace_runnable_dependencies_app_path. (flow_path above DOES need the manual
|
||||
// rewrite because flows are migrated via INSERT-new + DELETE-old, not UPDATE flow.path,
|
||||
// so the cascade never fires for them.)
|
||||
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
|
||||
@@ -58,6 +58,11 @@ pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
|
||||
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
|
||||
pub const NSJAIL_TMP_BACKING_DISK: &str = "disk";
|
||||
pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs";
|
||||
pub const SANDBOX_IMAGE_MAX_SIZE_MB_SETTING: &str = "sandbox_image_max_size_mb";
|
||||
pub const SANDBOX_IMAGE_CACHE_MAX_MB_SETTING: &str = "sandbox_image_cache_max_mb";
|
||||
pub const SANDBOX_IMAGE_PULL_POLICY_SETTING: &str = "sandbox_image_pull_policy";
|
||||
pub const SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING: &str = "sandbox_image_default_registry";
|
||||
pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth";
|
||||
pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
|
||||
pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
|
||||
|
||||
|
||||
@@ -976,6 +976,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[
|
||||
"ruby_repos",
|
||||
"powershell_repo_pat",
|
||||
"workspace_registries",
|
||||
"sandbox_registry_auth",
|
||||
];
|
||||
|
||||
/// Object-valued settings that contain sensitive sub-fields.
|
||||
|
||||
@@ -283,6 +283,24 @@ pub async fn shutdown_signal(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Defined for the whole non-unix scope (not just windows) so it can be a
|
||||
// plain `tokio::select!` branch: that macro does not accept `#[cfg(...)]`
|
||||
// attributes on individual branches. On non-windows non-unix targets the
|
||||
// future never resolves, so the branch is effectively inert there.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
async fn ctrl_break() -> std::io::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tokio::signal::windows::ctrl_break()?.recv().await;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
tokio::select! {
|
||||
_ = terminate() => {
|
||||
@@ -298,7 +316,12 @@ pub async fn shutdown_signal(
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {},
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("shutdown monitor received ctrl-c");
|
||||
},
|
||||
_ = ctrl_break() => {
|
||||
tracing::info!("shutdown monitor received ctrl-break");
|
||||
},
|
||||
_ = rx.recv() => {
|
||||
tracing::info!("shutdown monitor received killpill");
|
||||
},
|
||||
@@ -320,6 +343,9 @@ pub async fn shutdown_signal(
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::error!("2nd shutdown monitor received ctrl-c")
|
||||
},
|
||||
_ = ctrl_break() => {
|
||||
tracing::error!("2nd shutdown monitor received ctrl-break")
|
||||
},
|
||||
}
|
||||
|
||||
tracing::info!("Second terminate signal received, forcefully exiting");
|
||||
|
||||
@@ -35,6 +35,11 @@ pub struct LogContext {
|
||||
pub uri: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
|
||||
// Inbound W3C `traceparent` captured at enqueue (reserved `_wm_traceparent`
|
||||
// arg). Carried here so the worker's OTLP span and the script's injected
|
||||
// TRACEPARENT env can relocate into the originating distributed trace.
|
||||
pub inbound_traceparent: Option<String>,
|
||||
|
||||
// Auth (windmill-api-auth/src/auth.rs)
|
||||
pub email: Option<String>,
|
||||
pub username: Option<String>,
|
||||
|
||||
@@ -174,6 +174,26 @@ pub async fn generate_approval_token(
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || "view_token")`.
|
||||
/// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can
|
||||
/// never be used as a view token (or vice-versa). Used to build a "share read link" that
|
||||
/// grants an authenticated workspace member read access to a job (and its flow subtree)
|
||||
/// they otherwise lack ACL on. No expiry/revocation (stateless), like the approval token.
|
||||
pub async fn generate_view_token(
|
||||
w_id: &str,
|
||||
job_id: uuid::Uuid,
|
||||
db: &DB,
|
||||
) -> crate::error::Result<String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
||||
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
|
||||
mac.update(job_id.as_bytes());
|
||||
mac.update(b"view_token");
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
pub async fn get_secret_value_as_admin(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
|
||||
@@ -859,6 +859,37 @@ pub struct BashAnnotations {
|
||||
pub sandbox: bool,
|
||||
}
|
||||
|
||||
impl BashAnnotations {
|
||||
/// If the script declares `# sandbox <image>` (an image ref after the sandbox
|
||||
/// annotation), returns that image ref. This selects the daemonless, sandboxed
|
||||
/// container runtime: extract the image's rootfs and run it inside the job's
|
||||
/// nsjail sandbox.
|
||||
///
|
||||
/// A bare `# sandbox` (no image argument) returns `None` and keeps the plain
|
||||
/// nsjail-sandboxed-bash behavior (the `sandbox` boolean modifier). `# docker`
|
||||
/// is unaffected and keeps the legacy v1 (dind/daemon) path.
|
||||
pub fn sandbox_image(code: &str) -> Option<String> {
|
||||
for line in code.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Mirror the annotation parser: stop at the first non-comment line.
|
||||
if !line.starts_with('#') {
|
||||
break;
|
||||
}
|
||||
let mut tokens = line[1..].split_whitespace();
|
||||
if tokens.next() == Some("sandbox") {
|
||||
// `# sandbox <image>` -> container; bare `# sandbox` -> nsjail bash.
|
||||
if let Some(image) = tokens.next() {
|
||||
return Some(image.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SqlResultCollectionStrategy {
|
||||
LastStatementAllRows,
|
||||
@@ -2224,6 +2255,34 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_bash_sandbox_image_annotation() {
|
||||
// `# sandbox <image>` selects the container runtime and returns the image.
|
||||
assert_eq!(
|
||||
BashAnnotations::sandbox_image("# sandbox alpine:latest\necho hi"),
|
||||
Some("alpine:latest".to_string())
|
||||
);
|
||||
// Extra whitespace and a leading non-spaced `#` still work.
|
||||
assert_eq!(
|
||||
BashAnnotations::sandbox_image("#sandbox python:3.12-slim\n"),
|
||||
Some("python:3.12-slim".to_string())
|
||||
);
|
||||
// A bare `# sandbox` (no image) keeps the nsjail-bash modifier -> None.
|
||||
assert_eq!(BashAnnotations::sandbox_image("# sandbox\necho hi"), None);
|
||||
// `sandbox` must be its own token, not a prefix.
|
||||
assert_eq!(BashAnnotations::sandbox_image("# sandboxed foo"), None);
|
||||
// Stops at the first non-comment line (image declared too late is ignored).
|
||||
assert_eq!(
|
||||
BashAnnotations::sandbox_image("echo hi\n# sandbox alpine"),
|
||||
None
|
||||
);
|
||||
// `# docker` is a different annotation -> not a sandbox image.
|
||||
assert_eq!(
|
||||
BashAnnotations::sandbox_image("# docker alpine\necho hi"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_tags() {
|
||||
let input = vec![
|
||||
|
||||
@@ -157,7 +157,7 @@ pub enum ObjectType {
|
||||
WorkspaceDependencies,
|
||||
}
|
||||
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28238/sync-script-to-git-repo-windmill";
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28261/sync-script-to-git-repo-windmill";
|
||||
|
||||
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
|
||||
/// fork of another workspace.
|
||||
|
||||
@@ -29,3 +29,6 @@ http = { workspace = true, optional = true }
|
||||
tokio-util = { workspace = true, features = ["rt"], optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
futures.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
@@ -22,8 +22,6 @@ use rmcp::{
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use windmill_common::variables::get_secret_value_as_admin;
|
||||
use windmill_common::DB;
|
||||
|
||||
/// MCP client for communicating with external MCP servers
|
||||
pub struct McpClient {
|
||||
@@ -34,18 +32,29 @@ pub struct McpClient {
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Create a new MCP client from a resource configuration
|
||||
pub async fn from_resource(resource: McpResource, db: &DB, w_id: &str) -> Result<Self> {
|
||||
/// Create a new MCP client from a resource configuration.
|
||||
///
|
||||
/// `token`, when present, is the already-resolved bearer token sent as an
|
||||
/// `Authorization` header. It MUST be resolved by the caller through the
|
||||
/// permissioned (RLS + audit) variable path — `from_resource` never reads
|
||||
/// secrets itself, so a caller cannot trick it into decrypting a variable
|
||||
/// they are not allowed to read.
|
||||
pub async fn from_resource(resource: McpResource, token: Option<String>) -> Result<Self> {
|
||||
// The resource URL is author-controlled and we send a (potentially
|
||||
// secret) bearer token to it, so it must be validated against SSRF
|
||||
// before we connect (e.g. cloud metadata endpoints, internal services).
|
||||
windmill_common::ssrf::validate_url_for_ssrf(&resource.url)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("MCP server URL is not allowed: {}", e))?;
|
||||
|
||||
// Build custom reqwest client with headers if provided
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(token_path) = &resource.token {
|
||||
if !token_path.trim().is_empty() {
|
||||
let value =
|
||||
get_secret_value_as_admin(db, w_id, token_path.trim_start_matches("$var:"))
|
||||
.await?;
|
||||
if let Some(token) = token {
|
||||
let token = token.trim();
|
||||
if !token.is_empty() {
|
||||
headers.insert(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(format!("Bearer {}", value).as_str())?,
|
||||
HeaderValue::from_str(format!("Bearer {}", token).as_str())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +73,12 @@ impl McpClient {
|
||||
|
||||
let reqwest_client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
// Don't follow redirects: the SSRF check above only validates the
|
||||
// initial (author-controlled) URL, so following a redirect could
|
||||
// still reach a private/internal address with the bearer token
|
||||
// attached. The MCP streamable-HTTP endpoint is a direct endpoint
|
||||
// and does not legitimately rely on redirects.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
@@ -210,3 +225,32 @@ impl McpClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression test: `from_resource` must refuse to connect to a URL that
|
||||
/// targets a private/internal address (here the AWS
|
||||
/// instance-metadata endpoint), so a resource author cannot use the MCP
|
||||
/// client as an SSRF primitive against internal services. The guard runs
|
||||
/// before any connection attempt, so this fails fast without network access.
|
||||
#[tokio::test]
|
||||
async fn from_resource_rejects_ssrf_url() {
|
||||
let resource = McpResource {
|
||||
name: "evil".to_string(),
|
||||
url: "http://169.254.169.254".to_string(),
|
||||
token: None,
|
||||
headers: None,
|
||||
};
|
||||
|
||||
let msg = match McpClient::from_resource(resource, None).await {
|
||||
Ok(_) => panic!("a link-local metadata URL must be rejected before connecting"),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
assert!(
|
||||
msg.contains("not allowed") && msg.contains("private"),
|
||||
"error should explain the URL was rejected as private/internal, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ pub struct OAuthConfig {
|
||||
/// entry, `build_oauth_clients` registers a second client under that key.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<OAuthSandboxOverride>,
|
||||
/// Frontend-only metadata for per-instance OAuth providers (Snowflake,
|
||||
/// ServiceNow, …) whose authorize/token URLs are derived from an
|
||||
/// admin-entered instance name. Ignored by the backend, which only ever
|
||||
/// sees the resulting concrete `connect_config`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connect_config_template: Option<ConnectConfigTemplate>,
|
||||
}
|
||||
|
||||
/// URL overrides for an OAuth provider's sandbox environment. Inherits
|
||||
@@ -106,6 +112,43 @@ pub struct OAuthSandboxOverride {
|
||||
pub userinfo_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Frontend metadata for a per-instance OAuth provider. The instance-settings
|
||||
/// UI renders one generic instance-name input and substitutes `{instance}` into
|
||||
/// `auth_url`/`token_url` to build the per-client `connect_config`. Adding a new
|
||||
/// per-instance provider needs only a registry entry carrying this template —
|
||||
/// no frontend code change. The backend never reads it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ConnectConfigTemplate {
|
||||
/// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow");
|
||||
/// the UI falls back to a capitalized registry key when absent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
pub label: String,
|
||||
pub placeholder: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub help_url: Option<String>,
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub req_body_auth: Option<bool>,
|
||||
/// Key under `connect_config.extra_params` where the instance name is
|
||||
/// stored (defaults to `instance`). Snowflake uses `account_identifier` for
|
||||
/// backward compatibility with previously-saved configs.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_params_key: Option<String>,
|
||||
/// Optional host suffix stripped from the input before substitution (e.g.
|
||||
/// `.service-now.com`), so the admin can paste a full host or a bare name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strip_suffix: Option<String>,
|
||||
/// Maps OAuth-connected resource arg fields to value templates substituting
|
||||
/// `{instance}` (e.g. ServiceNow's `instance_url` ->
|
||||
/// `https://{instance}.service-now.com`). Applied by the resource-connect
|
||||
/// flow so the created resource carries the instance-specific fields the
|
||||
/// scripts need (ServiceNow's token response omits the host).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_mapping: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl OAuthConfig {
|
||||
/// Returns a copy of this config with sandbox URL overrides applied and
|
||||
/// the nested `sandbox` field cleared. Returns `None` if no overrides are
|
||||
@@ -817,6 +860,7 @@ mod tests {
|
||||
token_url: Some("https://account-d.example.com/oauth/token".to_string()),
|
||||
userinfo_url: None,
|
||||
}),
|
||||
connect_config_template: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5058,7 +5058,7 @@ async fn push_inner<'c, 'd>(
|
||||
content,
|
||||
path,
|
||||
hash,
|
||||
language,
|
||||
mut language,
|
||||
lock,
|
||||
cache_ttl,
|
||||
cache_ignore_s3_path,
|
||||
@@ -5068,6 +5068,21 @@ async fn push_inner<'c, 'd>(
|
||||
debouncing_settings,
|
||||
modules,
|
||||
}) => {
|
||||
// Reconcile the preview language with the `//native` annotation, mirroring the
|
||||
// deploy-time logic in `worker_lockfiles`. The editor sends `bun` for a TypeScript
|
||||
// script even when it carries `//native`, which would otherwise tag the preview as
|
||||
// `bun` and route it to a regular bun worker. A native-mode worker neither matches
|
||||
// the `bun` tag nor accepts a non-native `script_lang`, so previewing a `//native`
|
||||
// script on a native-only worker setup fails. Normalizing to `bunnative` (tag
|
||||
// `nativets`) makes the preview run exactly like the deployed script would.
|
||||
if language == ScriptLang::Bun || language == ScriptLang::Bunnative {
|
||||
let anns = windmill_common::worker::TypeScriptAnnotations::parse(&content);
|
||||
if anns.native && language == ScriptLang::Bun {
|
||||
language = ScriptLang::Bunnative;
|
||||
} else if !anns.native && language == ScriptLang::Bunnative {
|
||||
language = ScriptLang::Bun;
|
||||
}
|
||||
}
|
||||
// Inject modules into job args as _MODULES so the worker can extract them
|
||||
if let Some(ref modules) = modules {
|
||||
match serde_json::to_string(modules).and_then(|s| RawValue::from_string(s)) {
|
||||
@@ -5277,6 +5292,7 @@ async fn push_inner<'c, 'd>(
|
||||
expr: skip_handler.stop_condition,
|
||||
skip_if_stopped: true,
|
||||
error_message: Some(skip_handler.stop_message),
|
||||
error_include_result: false,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -45,6 +45,8 @@ tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
quick_cache.workspace = true
|
||||
lazy_static.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
sql-builder.workspace = true
|
||||
async-recursion.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
@@ -17,7 +17,7 @@ use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::rename_vault_secret;
|
||||
use crate::var_resource_cache::{cache_resource, get_cached_resource};
|
||||
use crate::var_resource_cache::{auth_identity, cache_resource, get_cached_resource};
|
||||
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
|
||||
use windmill_common::webhook::{WebhookMessage, WebhookShared};
|
||||
|
||||
@@ -564,8 +564,18 @@ pub async fn get_resource_value_interpolated_internal<'a>(
|
||||
return Ok(Some(pg_creds));
|
||||
}
|
||||
|
||||
if allow_cache {
|
||||
if let Some(cached_value) = get_cached_resource(&workspace, &path) {
|
||||
// Scope the cache to the caller's full authorization identity (not just email): the
|
||||
// cached value is already decrypted/interpolated under this caller's RLS context, so it
|
||||
// must never be served to a context that resolves to different permissions. Only
|
||||
// job-independent values are ever stored (see the write below), so a hit is always safe
|
||||
// to return regardless of the current `job_id`.
|
||||
let cache_identity = allow_cache.then(|| match db_with_opt_authed.authed() {
|
||||
Some(authed) => auth_identity(authed),
|
||||
None => format!("\0system:{}", db_with_opt_authed.email()),
|
||||
});
|
||||
|
||||
if let Some(identity) = cache_identity.as_deref() {
|
||||
if let Some(cached_value) = get_cached_resource(&workspace, &path, identity) {
|
||||
return Ok(Some(cached_value));
|
||||
}
|
||||
}
|
||||
@@ -589,17 +599,24 @@ pub async fn get_resource_value_interpolated_internal<'a>(
|
||||
|
||||
let value = not_found_if_none(value_o, "Resource", path)?;
|
||||
if let Some(value) = value {
|
||||
let r = transform_json_value(
|
||||
// Track whether interpolation pulled in a `$WM_*` contextual variable. If it did, the
|
||||
// result is job-dependent (and may embed `$WM_TOKEN`) and must not be cached; if not,
|
||||
// it's job-independent and safe to cache and to serve to any job context.
|
||||
let used_job_context = std::sync::atomic::AtomicBool::new(false);
|
||||
let r = transform_json_value_tracked(
|
||||
&db_with_opt_authed,
|
||||
workspace,
|
||||
value,
|
||||
&job_id,
|
||||
token_for_context,
|
||||
0,
|
||||
&used_job_context,
|
||||
)
|
||||
.await?;
|
||||
if allow_cache {
|
||||
cache_resource(&workspace, &path, r.clone());
|
||||
if let Some(identity) = cache_identity.as_deref() {
|
||||
if !used_job_context.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
cache_resource(&workspace, &path, identity, r.clone());
|
||||
}
|
||||
}
|
||||
Ok(Some(r))
|
||||
} else {
|
||||
@@ -615,14 +632,41 @@ pub async fn get_resource_value_interpolated_internal<'a>(
|
||||
// access could otherwise use to crash the API process.
|
||||
pub const MAX_RESOURCE_INTERPOLATION_DEPTH: u8 = 50;
|
||||
|
||||
#[async_recursion]
|
||||
pub async fn transform_json_value(
|
||||
db_with_opt_authed: &DbWithOptAuthed<ApiAuthed>,
|
||||
db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>,
|
||||
workspace: &str,
|
||||
v: Value,
|
||||
job_id: &Option<Uuid>,
|
||||
token: Option<&str>,
|
||||
depth: u8,
|
||||
) -> Result<Value> {
|
||||
// Discard the job-context flag; callers that need it use `transform_json_value_tracked`.
|
||||
let used_job_context = std::sync::atomic::AtomicBool::new(false);
|
||||
transform_json_value_tracked(
|
||||
db_with_opt_authed,
|
||||
workspace,
|
||||
v,
|
||||
job_id,
|
||||
token,
|
||||
depth,
|
||||
&used_job_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`transform_json_value`], but records into `used_job_context` whether the value
|
||||
/// contains a `$WM_*` contextual variable (resolved from `job_id`/`token`). A value that did
|
||||
/// not is job-independent and safe to cache; one that did must not be cached or shared across
|
||||
/// jobs.
|
||||
#[async_recursion]
|
||||
pub async fn transform_json_value_tracked(
|
||||
db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>,
|
||||
workspace: &str,
|
||||
v: Value,
|
||||
job_id: &Option<Uuid>,
|
||||
token: Option<&str>,
|
||||
depth: u8,
|
||||
used_job_context: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<Value> {
|
||||
if depth >= MAX_RESOURCE_INTERPOLATION_DEPTH {
|
||||
return Err(Error::internal_err(format!(
|
||||
@@ -666,15 +710,35 @@ pub async fn transform_json_value(
|
||||
tx.commit().await?;
|
||||
let v = not_found_if_none(v, "Resource", path)?;
|
||||
if let Some(v) = v {
|
||||
transform_json_value(db_with_opt_authed, workspace, v, job_id, token, depth + 1)
|
||||
.await
|
||||
transform_json_value_tracked(
|
||||
db_with_opt_authed,
|
||||
workspace,
|
||||
v,
|
||||
job_id,
|
||||
token,
|
||||
depth + 1,
|
||||
used_job_context,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
}
|
||||
Value::String(y) if y.starts_with("$") && job_id.is_some() => {
|
||||
// `$WM_*` is the reserved contextual-variable namespace (`$WM_TOKEN`, `$WM_JOB_ID`,
|
||||
// ...); its resolved value depends on the job, so a value containing one is
|
||||
// job-dependent and must never be cached — including on a no-job read, where the
|
||||
// placeholder is left unresolved (caching it would then serve a stale placeholder to a
|
||||
// later job read). Any other `$...` string (custom workspace envs, `$5.00`, `$HOME`, jq
|
||||
// paths) is NOT interpolated here — it resolves to itself regardless of context and so
|
||||
// stays cacheable (handled by the catch-all below). Note: custom workspace envs are
|
||||
// intentionally not resolved inside resource values (they remain available to scripts).
|
||||
Value::String(y) if y.starts_with("$WM_") => {
|
||||
used_job_context.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some(job_id) = *job_id else {
|
||||
// No job context to resolve against; leave the placeholder unchanged.
|
||||
return Ok(Value::String(y));
|
||||
};
|
||||
let mut tx = db_with_opt_authed.begin().await?;
|
||||
let job_id = job_id.unwrap();
|
||||
let job = sqlx::query!(
|
||||
"SELECT
|
||||
v2_job.permissioned_as_email,
|
||||
@@ -745,13 +809,14 @@ pub async fn transform_json_value(
|
||||
Value::Array(mut arr) if depth <= 2 && arr.len() <= 1000 => {
|
||||
for i in 0..arr.len() {
|
||||
let val = std::mem::take(&mut arr[i]);
|
||||
arr[i] = transform_json_value(
|
||||
arr[i] = transform_json_value_tracked(
|
||||
db_with_opt_authed,
|
||||
workspace,
|
||||
val,
|
||||
job_id,
|
||||
token,
|
||||
depth + 1,
|
||||
used_job_context,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -768,13 +833,14 @@ pub async fn transform_json_value(
|
||||
}
|
||||
Value::Object(mut m) => {
|
||||
for (a, b) in m.clone().into_iter() {
|
||||
let v = transform_json_value(
|
||||
let v = transform_json_value_tracked(
|
||||
db_with_opt_authed,
|
||||
workspace,
|
||||
b,
|
||||
job_id,
|
||||
token,
|
||||
depth + 1,
|
||||
used_job_context,
|
||||
)
|
||||
.await?;
|
||||
m.insert(a.clone(), v);
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
use quick_cache::sync::Cache;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use windmill_common::db::Authable;
|
||||
|
||||
/// Cache TTL for variables and resources (30seconds)
|
||||
const CACHE_TTL_SECS: u64 = 30;
|
||||
@@ -40,11 +42,23 @@ impl<T> CacheEntry<T> {
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Cache for individual variable values: key = "workspace_id:path"
|
||||
pub static ref VARIABLE_CACHE: Cache<String, CacheEntry<String>> = Cache::new(1000);
|
||||
/// A cached variable value plus whether it is a secret. `is_secret` is retained so a
|
||||
/// cache hit can re-run the per-read side effects of a secret read (the
|
||||
/// `variables.decrypt_secret` audit and running-job secret registration) that the
|
||||
/// original miss performed — a hit must be observably equivalent to a miss.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CachedVariable {
|
||||
pub value: String,
|
||||
pub is_secret: bool,
|
||||
}
|
||||
|
||||
/// Cache for resource values: key = "workspace_id:path"
|
||||
lazy_static::lazy_static! {
|
||||
/// Cache for individual variable values. Key: [`identity_cache_key`]
|
||||
/// (`identity:workspace_id:path`) — scoped to the caller's authorization context.
|
||||
pub static ref VARIABLE_CACHE: Cache<String, CacheEntry<CachedVariable>> = Cache::new(1000);
|
||||
|
||||
/// Cache for interpolated resource values. Key: [`identity_cache_key`]
|
||||
/// (`identity:workspace_id:path`) — scoped to the caller's authorization context.
|
||||
pub static ref RESOURCE_CACHE: Cache<String, CacheEntry<Value>> = Cache::new(1000);
|
||||
}
|
||||
|
||||
@@ -53,9 +67,73 @@ pub fn cache_key(workspace_id: &str, path: &str) -> String {
|
||||
format!("{}:{}", workspace_id, path)
|
||||
}
|
||||
|
||||
/// Get cached variable if available and not expired
|
||||
pub fn get_cached_variable(workspace_id: &str, path: &str) -> Option<String> {
|
||||
let key = cache_key(workspace_id, path);
|
||||
/// Hash the caller's full authorization context into a stable identity string.
|
||||
///
|
||||
/// Email alone is **not** a sufficient scope: the same email can resolve to different
|
||||
/// effective permissions (`username`, groups, folders, scopes, admin/operator) through
|
||||
/// job- or owner-scoped tokens that share an email but carry a narrower `permissioned_as`.
|
||||
/// Every input that determines what the caller may read is folded in, mirroring
|
||||
/// `job_read_access_cache_key` in windmill-api, so a lower-privilege context can never
|
||||
/// reuse a higher-privilege context's cache entry. Variable-length fields are
|
||||
/// length-prefixed to keep the encoding injective.
|
||||
pub fn auth_identity<A: Authable + ?Sized>(authed: &A) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
let field = |hasher: &mut Sha256, bytes: &[u8]| {
|
||||
hasher.update((bytes.len() as u32).to_be_bytes());
|
||||
hasher.update(bytes);
|
||||
};
|
||||
hasher.update([authed.is_admin() as u8, authed.is_operator() as u8]);
|
||||
field(&mut hasher, authed.email().as_bytes());
|
||||
field(&mut hasher, authed.username().as_bytes());
|
||||
let mut groups: Vec<&str> = authed.groups().iter().map(String::as_str).collect();
|
||||
groups.sort_unstable();
|
||||
hasher.update((groups.len() as u32).to_be_bytes());
|
||||
for g in groups {
|
||||
field(&mut hasher, g.as_bytes());
|
||||
}
|
||||
let mut folders: Vec<&str> = authed.folders().iter().map(|f| f.0.as_str()).collect();
|
||||
folders.sort_unstable();
|
||||
hasher.update((folders.len() as u32).to_be_bytes());
|
||||
for f in folders {
|
||||
field(&mut hasher, f.as_bytes());
|
||||
}
|
||||
match authed.scopes() {
|
||||
// u32::MAX length-prefix marks "no scopes" so it can't collide with an empty list.
|
||||
None => hasher.update(u32::MAX.to_be_bytes()),
|
||||
Some(scopes) => {
|
||||
let mut scopes: Vec<&str> = scopes.iter().map(String::as_str).collect();
|
||||
scopes.sort_unstable();
|
||||
hasher.update((scopes.len() as u32).to_be_bytes());
|
||||
for s in scopes {
|
||||
field(&mut hasher, s.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Generate an identity-scoped cache key (`identity:workspace_id:path`).
|
||||
///
|
||||
/// Both the variable and resource caches store *already-decrypted* values that were
|
||||
/// resolved under the caller's row-level-security context. The cache is consulted before
|
||||
/// the per-folder RLS query runs, so an unscoped `workspace:path` key would let an entry
|
||||
/// warmed by one caller (via `allow_cache=true`) be served to a different caller who has
|
||||
/// no access to the underlying folder, leaking decrypted secrets within the TTL. `identity`
|
||||
/// is [`auth_identity`] — the hash of the caller's full authorization context — so a hit
|
||||
/// can only ever be returned to a caller whose authorized read populated it.
|
||||
fn identity_cache_key(identity: &str, workspace_id: &str, path: &str) -> String {
|
||||
format!("{}:{}", identity, cache_key(workspace_id, path))
|
||||
}
|
||||
|
||||
/// Get cached variable if available and not expired. Scoped to `identity`
|
||||
/// ([`auth_identity`]); see [`identity_cache_key`]. Returns the value and its `is_secret`
|
||||
/// flag so the caller can re-run a secret read's side effects on a hit.
|
||||
pub fn get_cached_variable(
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
identity: &str,
|
||||
) -> Option<CachedVariable> {
|
||||
let key = identity_cache_key(identity, workspace_id, path);
|
||||
VARIABLE_CACHE.get(&key).and_then(|entry| {
|
||||
if entry.is_expired() {
|
||||
VARIABLE_CACHE.remove(&key);
|
||||
@@ -67,17 +145,21 @@ pub fn get_cached_variable(workspace_id: &str, path: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Cache variable data
|
||||
pub fn cache_variable(workspace_id: &str, path: &str, email: &str, variable: String) {
|
||||
let key = format!("{}:{}", email, cache_key(workspace_id, path));
|
||||
/// Cache variable data, scoped to the caller identity. See [`get_cached_variable`].
|
||||
pub fn cache_variable(workspace_id: &str, path: &str, identity: &str, variable: CachedVariable) {
|
||||
let key = identity_cache_key(identity, workspace_id, path);
|
||||
let entry = CacheEntry::new(variable);
|
||||
VARIABLE_CACHE.insert(key.clone(), entry);
|
||||
tracing::debug!("Cached variable {}", key);
|
||||
}
|
||||
|
||||
/// Get cached resource if available and not expired
|
||||
pub fn get_cached_resource(workspace_id: &str, path: &str) -> Option<Value> {
|
||||
let key = cache_key(workspace_id, path);
|
||||
/// Get cached resource if available and not expired.
|
||||
///
|
||||
/// Scoped to `identity` ([`auth_identity`]); see [`identity_cache_key`]. The cached value
|
||||
/// is the *already-interpolated* resource — its `$var:`/`$res:` secrets are resolved and
|
||||
/// decrypted inline — so it must never cross authorization boundaries.
|
||||
pub fn get_cached_resource(workspace_id: &str, path: &str, identity: &str) -> Option<Value> {
|
||||
let key = identity_cache_key(identity, workspace_id, path);
|
||||
RESOURCE_CACHE.get(&key).and_then(|entry| {
|
||||
if entry.is_expired() {
|
||||
RESOURCE_CACHE.remove(&key);
|
||||
@@ -89,22 +171,28 @@ pub fn get_cached_resource(workspace_id: &str, path: &str) -> Option<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Cache resource data
|
||||
pub fn cache_resource(workspace_id: &str, path: &str, resource: Value) {
|
||||
let key = cache_key(workspace_id, path);
|
||||
/// Cache resource data, scoped to the caller identity. See [`get_cached_resource`].
|
||||
pub fn cache_resource(workspace_id: &str, path: &str, identity: &str, resource: Value) {
|
||||
let key = identity_cache_key(identity, workspace_id, path);
|
||||
let entry = CacheEntry::new(resource);
|
||||
RESOURCE_CACHE.insert(key.clone(), entry);
|
||||
tracing::debug!("Cached resource {}", key);
|
||||
}
|
||||
|
||||
/// Invalidate specific variable from cache
|
||||
/// Invalidate a variable from the cache.
|
||||
///
|
||||
/// NOTE: entries are keyed by [`identity_cache_key`] (`identity:workspace:path`), so this
|
||||
/// `workspace:path` key cannot target them — it only removes a legacy unscoped entry, if
|
||||
/// any. Per-identity entries are not enumerable here; rely on the 30s TTL for staleness,
|
||||
/// or use [`clear_all_caches`] to force a full flush. Currently unused.
|
||||
pub fn invalidate_variable_cache(workspace_id: &str, path: &str) {
|
||||
let key = cache_key(workspace_id, path);
|
||||
VARIABLE_CACHE.remove(&key);
|
||||
tracing::info!("Variable cache invalidated for {}", key);
|
||||
}
|
||||
|
||||
/// Invalidate specific resource from cache
|
||||
/// Invalidate a resource from the cache. Same identity-scoping caveat as
|
||||
/// [`invalidate_variable_cache`]. Currently unused.
|
||||
pub fn invalidate_resource_cache(workspace_id: &str, path: &str) {
|
||||
let key = cache_key(workspace_id, path);
|
||||
RESOURCE_CACHE.remove(&key);
|
||||
@@ -118,3 +206,106 @@ pub fn clear_all_caches() {
|
||||
RESOURCE_CACHE.clear();
|
||||
tracing::debug!("All variable/resource caches cleared");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal [`Authable`] double so we can assert which authorization fields the
|
||||
/// cache identity is sensitive to, without standing up a full auth stack.
|
||||
struct FakeAuthed {
|
||||
email: String,
|
||||
username: String,
|
||||
is_admin: bool,
|
||||
is_operator: bool,
|
||||
groups: Vec<String>,
|
||||
folders: Vec<(String, bool, bool)>,
|
||||
scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeAuthed {
|
||||
fn base() -> Self {
|
||||
Self {
|
||||
email: "alice@x.dev".to_string(),
|
||||
username: "alice".to_string(),
|
||||
is_admin: false,
|
||||
is_operator: false,
|
||||
groups: vec!["all".to_string()],
|
||||
folders: vec![("shared".to_string(), false, false)],
|
||||
scopes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Authable for FakeAuthed {
|
||||
fn email(&self) -> &str {
|
||||
&self.email
|
||||
}
|
||||
fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
fn is_admin(&self) -> bool {
|
||||
self.is_admin
|
||||
}
|
||||
fn is_operator(&self) -> bool {
|
||||
self.is_operator
|
||||
}
|
||||
fn groups(&self) -> &[String] {
|
||||
&self.groups
|
||||
}
|
||||
fn folders(&self) -> &[(String, bool, bool)] {
|
||||
&self.folders
|
||||
}
|
||||
fn scopes(&self) -> Option<&[String]> {
|
||||
self.scopes.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// Email alone must NOT determine the cache identity: two contexts that share an email
|
||||
// but resolve to different effective permissions must get distinct identities, so a
|
||||
// lower-privilege context can never reuse a higher-privilege one's cached secret.
|
||||
#[test]
|
||||
fn auth_identity_is_not_just_email() {
|
||||
let base = auth_identity(&FakeAuthed::base());
|
||||
|
||||
let mut more_folders = FakeAuthed::base();
|
||||
more_folders
|
||||
.folders
|
||||
.push(("secret".to_string(), false, false));
|
||||
assert_ne!(base, auth_identity(&more_folders), "folders must matter");
|
||||
|
||||
let mut more_groups = FakeAuthed::base();
|
||||
more_groups.groups.push(("devs").to_string());
|
||||
assert_ne!(base, auth_identity(&more_groups), "groups must matter");
|
||||
|
||||
let mut other_user = FakeAuthed::base();
|
||||
other_user.username = "bob".to_string();
|
||||
assert_ne!(base, auth_identity(&other_user), "username must matter");
|
||||
|
||||
let mut admin = FakeAuthed::base();
|
||||
admin.is_admin = true;
|
||||
assert_ne!(base, auth_identity(&admin), "is_admin must matter");
|
||||
|
||||
let mut operator = FakeAuthed::base();
|
||||
operator.is_operator = true;
|
||||
assert_ne!(base, auth_identity(&operator), "is_operator must matter");
|
||||
|
||||
let mut scoped = FakeAuthed::base();
|
||||
scoped.scopes = Some(vec!["resources:read:f/secret/x".to_string()]);
|
||||
assert_ne!(base, auth_identity(&scoped), "scopes must matter");
|
||||
}
|
||||
|
||||
// Identical authorization contexts must produce the same identity (so the same caller
|
||||
// gets a cache hit), and ordering of groups/folders must not change the identity.
|
||||
#[test]
|
||||
fn auth_identity_is_stable_and_order_independent() {
|
||||
let a = FakeAuthed::base();
|
||||
assert_eq!(auth_identity(&a), auth_identity(&FakeAuthed::base()));
|
||||
|
||||
let mut reordered = FakeAuthed::base();
|
||||
reordered.groups = vec!["all".to_string(), "devs".to_string()];
|
||||
let mut other_order = FakeAuthed::base();
|
||||
other_order.groups = vec!["devs".to_string(), "all".to_string()];
|
||||
assert_eq!(auth_identity(&reordered), auth_identity(&other_order));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ use windmill_common::{
|
||||
worker::CLOUD_HOSTED,
|
||||
};
|
||||
|
||||
use crate::var_resource_cache::{cache_variable, get_cached_variable};
|
||||
use crate::var_resource_cache::{
|
||||
auth_identity, cache_variable, get_cached_variable, CachedVariable,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{Acquire, Postgres, Transaction};
|
||||
@@ -1230,15 +1232,55 @@ fn replace_path(v: serde_json::Value, path: &str, npath: &str) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the `variables.decrypt_secret` audit event for a secret-variable read. Run on both
|
||||
/// the cache-miss and cache-hit paths so `allow_cache` never skips secret-access auditing.
|
||||
async fn audit_decrypt_secret(
|
||||
db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
let mut tx = db_with_opt_authed.db().begin().await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
db_with_opt_authed,
|
||||
"variables.decrypt_secret",
|
||||
ActionKind::Execute,
|
||||
w_id,
|
||||
Some(path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_value_internal<'a>(
|
||||
db_with_opt_authed: &'a DbWithOptAuthed<'a, ApiAuthed>,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
allow_cache: bool,
|
||||
) -> Result<String> {
|
||||
if allow_cache {
|
||||
if let Some(cached_variable) = get_cached_variable(&w_id, &path) {
|
||||
return Ok(cached_variable);
|
||||
// Scope the cache to the caller's full authorization identity (not just email): the
|
||||
// cached value is the decrypted variable, resolved under this caller's RLS context.
|
||||
let cache_identity = allow_cache.then(|| match db_with_opt_authed.authed() {
|
||||
Some(authed) => auth_identity(authed),
|
||||
None => format!("\0system:{}", db_with_opt_authed.email()),
|
||||
});
|
||||
|
||||
if let Some(identity) = cache_identity.as_deref() {
|
||||
if let Some(cached) = get_cached_variable(&w_id, &path, identity) {
|
||||
// A cache hit must be observably equivalent to a miss: re-run the per-read side
|
||||
// effects a secret read performs (the `variables.decrypt_secret` audit and
|
||||
// running-job secret registration) so `allow_cache` never silently skips them.
|
||||
if cached.is_secret {
|
||||
audit_decrypt_secret(db_with_opt_authed, &w_id, &path).await?;
|
||||
if !cached.value.is_empty() {
|
||||
windmill_common::sensitive_log_masks::register_secret_for_all_running_jobs(
|
||||
&cached.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(cached.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,19 +1302,7 @@ pub async fn get_value_internal<'a>(
|
||||
};
|
||||
|
||||
let r = if variable.is_secret {
|
||||
// let audit_author =
|
||||
let mut tx = db_with_opt_authed.db().begin().await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
db_with_opt_authed,
|
||||
"variables.decrypt_secret",
|
||||
ActionKind::Execute,
|
||||
&w_id,
|
||||
Some(&variable.path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
audit_decrypt_secret(db_with_opt_authed, &w_id, &variable.path).await?;
|
||||
|
||||
let value = variable.value;
|
||||
if variable.is_expired.unwrap_or(false) && variable.account.is_some() {
|
||||
@@ -1308,9 +1338,16 @@ pub async fn get_value_internal<'a>(
|
||||
windmill_common::sensitive_log_masks::register_secret_for_all_running_jobs(&r);
|
||||
}
|
||||
|
||||
// Cache the result when explicitly allowed and caching appropriate
|
||||
if allow_cache {
|
||||
cache_variable(&w_id, &path, db_with_opt_authed.email(), r.clone());
|
||||
// Cache the result when explicitly allowed. Secrets are cached too: their per-read side
|
||||
// effects (audit + running-job registration) are re-run on a hit (see the hit path above),
|
||||
// and `is_secret` is stored so the hit knows to do so.
|
||||
if let Some(identity) = cache_identity.as_deref() {
|
||||
cache_variable(
|
||||
&w_id,
|
||||
&path,
|
||||
identity,
|
||||
CachedVariable { value: r.clone(), is_secret: variable.is_secret },
|
||||
);
|
||||
}
|
||||
|
||||
Ok(r)
|
||||
|
||||
@@ -914,6 +914,26 @@ pub async fn run_deployed_relative_imports(
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Regression guard for the Deno lock-gen import map (generate_deno_lock):
|
||||
// it must resolve workspace `/f/`/`/u/` imports, otherwise `deno cache --lock`
|
||||
// fails with "not a dependency and not in import map". We match that
|
||||
// specific failure rather than asserting lock_error_logs is empty —
|
||||
// the field also captures benign, non-fatal lock-job output (e.g. Bun's
|
||||
// "empty dependencies, skipping install"). (Runtime query to avoid
|
||||
// touching the sqlx offline cache.)
|
||||
let lock_error: Option<String> =
|
||||
sqlx::query_scalar("SELECT lock_error_logs FROM script WHERE path = $1")
|
||||
.bind("f/system/test_import")
|
||||
.fetch_one(&db2)
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(err) = &lock_error {
|
||||
assert!(
|
||||
!err.contains("not in import map"),
|
||||
"lock generation failed to resolve a workspace import: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
let job = RunJob::from(JobPayload::ScriptHash {
|
||||
path: "f/system/test_import".to_string(),
|
||||
hash: ScriptHash(script.hash),
|
||||
|
||||
@@ -337,6 +337,20 @@ mod zoom {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Prevent this challenge endpoint from being used as a signing oracle.
|
||||
// Legitimate Zoom validation tokens are short random hex strings that
|
||||
// never contain colons. The exploit requires crafting a plainToken in the
|
||||
// `v0:{timestamp}:{body}` webhook-signing format (always containing colons)
|
||||
// to obtain a valid signature for an arbitrary body. Reject any token that
|
||||
// does not look like a legitimate Zoom validation token.
|
||||
if zoom_request_body.payload.plain_token.contains(':')
|
||||
|| zoom_request_body.payload.plain_token.len() > 128
|
||||
{
|
||||
return Err(AuthenticationError::InvalidChallengeResponse(
|
||||
"Zoom: invalid plainToken format".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let hmac_signature = calculate_hmac_signature(
|
||||
HmacAlgorithm::Sha256,
|
||||
&signature_config_data.secret_key,
|
||||
@@ -1540,6 +1554,52 @@ mod tests {
|
||||
assert!(response.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zoom_challenge_normal_token_succeeds() {
|
||||
// A legitimate Zoom validation token is a short random alphanumeric string.
|
||||
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#;
|
||||
|
||||
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
||||
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
|
||||
let response = handler
|
||||
.handle_challenge_request(&HeaderMap::new(), &config_data, payload)
|
||||
.unwrap();
|
||||
assert!(response.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zoom_challenge_token_with_colons_rejected() {
|
||||
// Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format
|
||||
// would let an attacker obtain a valid webhook signature for an arbitrary body.
|
||||
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#;
|
||||
|
||||
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
||||
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
|
||||
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(AuthenticationError::InvalidChallengeResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zoom_challenge_token_too_long_rejected() {
|
||||
// A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token.
|
||||
let long_token = "a".repeat(129);
|
||||
let payload = format!(
|
||||
r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#,
|
||||
long_token
|
||||
);
|
||||
|
||||
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
|
||||
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
|
||||
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(AuthenticationError::InvalidChallengeResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
// --- Custom webhook end-to-end ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -327,6 +327,11 @@ pub struct StopAfterIf {
|
||||
pub expr: String,
|
||||
pub skip_if_stopped: bool,
|
||||
pub error_message: Option<String>,
|
||||
/// When stopping with an error (`error_message` set), embed the stopping
|
||||
/// step's own result inside the raised error object (as `error.result`)
|
||||
/// instead of discarding it. The top-level result stays `{ "error": .. }`.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub error_include_result: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)]
|
||||
|
||||
@@ -557,6 +557,13 @@ pub struct OnBehalfOf {
|
||||
|
||||
pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE";
|
||||
|
||||
/// Reserved job-arg key holding the inbound W3C `traceparent` captured from the
|
||||
/// request that enqueued the job (run endpoints). It rides the `args` jsonb like
|
||||
/// [`ENTRYPOINT_OVERRIDE`]; normal scripts never see it because args are bound by
|
||||
/// declared parameter name. Read back at root-job completion to link the job's
|
||||
/// OTLP span to the originating distributed trace (EE/OTel only).
|
||||
pub const WM_TRACEPARENT: &str = "_wm_traceparent";
|
||||
|
||||
/// The entrypoint override (`_ENTRYPOINT_OVERRIDE` job arg ->
|
||||
/// `v2_job.script_entrypoint_override`) is interpolated verbatim into
|
||||
/// generated worker wrappers in a code position (e.g. the NativeTS
|
||||
|
||||
@@ -5,10 +5,21 @@ hostname: "python"
|
||||
log_level: ERROR
|
||||
time_limit: 900
|
||||
|
||||
rlimit_as: 2048
|
||||
# uv's --compile-bytecode spawns a bytecode-compile thread pool sized to the
|
||||
# host's CPU count. Each thread reserves virtual address space for its stack, so
|
||||
# on high-core machines the aggregate overruns a low rlimit_as and installs fail
|
||||
# intermittently with "OS can't spawn worker thread: Resource temporarily
|
||||
# unavailable (os error 11)" / "memory allocation failed". A low cap (was 2048)
|
||||
# is the address-space companion to the fd exhaustion fixed below; raised well
|
||||
# above the run sandbox's 4096 to give the compile pool headroom on large nodes.
|
||||
rlimit_as: 8192
|
||||
rlimit_cpu: 1000
|
||||
rlimit_fsize: 1024
|
||||
rlimit_nofile: 64
|
||||
# uv's --compile-bytecode spawns a Python interpreter that compiles .py files
|
||||
# with parallelism scaling to the host's CPU count, opening many fds at once.
|
||||
# A low cap (was 64) is exhausted on high-core machines -> "Too many open files".
|
||||
# Matches the runtime configs (run.python3/run.ansible) which already use 10000.
|
||||
rlimit_nofile: 10000
|
||||
|
||||
envar: "HOME=/user"
|
||||
envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: "docker v2 run"
|
||||
|
||||
mode: ONCE
|
||||
hostname: "container"
|
||||
log_level: ERROR
|
||||
time_limit: {TIMEOUT}
|
||||
|
||||
disable_rl: true
|
||||
|
||||
cwd: {WORKDIR}
|
||||
|
||||
clone_newnet: false
|
||||
clone_newuser: {CLONE_NEWUSER}
|
||||
|
||||
skip_setsid: true
|
||||
keep_caps: false
|
||||
# keep_env forwards nsjail's OWN process env (only windmill-trusted keys: reserved
|
||||
# vars + proxy) to the child. The image's attacker-controlled Env is delivered via
|
||||
# the envar directives below — NEVER nsjail's process env, so a hostile image cannot
|
||||
# set LD_PRELOAD/LD_LIBRARY_PATH/LD_AUDIT on the nsjail binary itself.
|
||||
keep_env: true
|
||||
mount_proc: true
|
||||
|
||||
# Image Env (+ PATH/HOME fallbacks), proto-escaped. Applied to the child only.
|
||||
{ENVARS}
|
||||
|
||||
# Map uid/gid 0 inside the jail to the (single) worker user outside. The image's
|
||||
# rootfs is extracted as the worker user, so a root process inside the container
|
||||
# owns the rootfs and runs like a normal "root in container" — without any subuid
|
||||
# range. Multi-uid images are a later enhancement (newuidmap range).
|
||||
uidmap {
|
||||
inside_id: "0"
|
||||
outside_id: ""
|
||||
count: 1
|
||||
}
|
||||
gidmap {
|
||||
inside_id: "0"
|
||||
outside_id: ""
|
||||
count: 1
|
||||
}
|
||||
|
||||
# The image's root filesystem, bound one top-level entry at a time. Binding the
|
||||
# whole rootfs at "/" trips nsjail's read-only remount of its base root in a
|
||||
# rootless userns ("mount(... MS_REMOUNT|MS_BIND|MS_RDONLY): Operation not
|
||||
# permitted"); per-entry binds sit as rw submounts under nsjail's own tmpfs root
|
||||
# and avoid it. Generated from the extracted rootfs.
|
||||
{ROOTFS_MOUNTS}
|
||||
|
||||
# Pseudo-filesystems the image expects. /tmp honors the same instance settings as
|
||||
# every other nsjail job (nsjail_tmp_backing tmpfs/disk, nsjail_tmpfs_size_mb);
|
||||
# /dev gets the standard nodes; /proc comes from mount_proc (the jail's own pid ns).
|
||||
{TMP_MOUNT_BLOCK}
|
||||
|
||||
mount {
|
||||
src: "/dev/null"
|
||||
dst: "/dev/null"
|
||||
is_bind: true
|
||||
rw: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/dev/zero"
|
||||
dst: "/dev/zero"
|
||||
is_bind: true
|
||||
rw: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/dev/random"
|
||||
dst: "/dev/random"
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/dev/urandom"
|
||||
dst: "/dev/urandom"
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
# Host DNS config layered over the image's /etc so name resolution works on the
|
||||
# job's network (mandatory:false: some minimal images have no /etc files to shadow).
|
||||
mount {
|
||||
src: "/etc/resolv.conf"
|
||||
dst: "/etc/resolv.conf"
|
||||
is_bind: true
|
||||
mandatory: false
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/etc/hosts"
|
||||
dst: "/etc/hosts"
|
||||
is_bind: true
|
||||
mandatory: false
|
||||
}
|
||||
|
||||
# `# volume` mounts (and the same-worker /tmp/shared folder). Placed after the
|
||||
# rootfs binds and the tmpfs /tmp so a volume target overrides any colliding image
|
||||
# path and isn't shadowed by the tmpfs. Empty when there are no volumes.
|
||||
{SHARED_MOUNT}
|
||||
|
||||
iface_no_lo: true
|
||||
|
||||
#{DEV}
|
||||
@@ -7,6 +7,8 @@ use std::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use windmill_ai::types::*;
|
||||
#[cfg(feature = "mcp")]
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::flows::FlowModuleValue;
|
||||
use windmill_common::{
|
||||
db::DB,
|
||||
@@ -546,7 +548,7 @@ pub async fn load_mcp_tools(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
mcp_configs: Vec<McpResourceConfig>,
|
||||
auth_token: &str,
|
||||
client: &AuthedClient,
|
||||
) -> Result<(HashMap<String, Arc<McpClient>>, Vec<Tool>), Error> {
|
||||
let mut all_mcp_tools = Vec::new();
|
||||
let mut mcp_clients = HashMap::new();
|
||||
@@ -573,27 +575,47 @@ pub async fn load_mcp_tools(
|
||||
|
||||
let resource_name = mcp_resource.name.clone();
|
||||
|
||||
// Check if token needs refresh before creating MCP client
|
||||
if let Some(ref token_path) = mcp_resource.token {
|
||||
// Resolve the token through the job's permissioned (RLS + audit) path so
|
||||
// the AI agent cannot exfiltrate a secret its identity is not allowed to
|
||||
// read by pointing an MCP resource's token at it.
|
||||
let token = if let Some(ref token_path) = mcp_resource.token {
|
||||
let token_var_path = token_path.trim_start_matches("$var:");
|
||||
if let Err(e) =
|
||||
refresh_token_if_expired(db, workspace_id, token_var_path, auth_token).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.",
|
||||
resource_name, e
|
||||
);
|
||||
if token_var_path.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Refresh first (best-effort) so the value we read is current.
|
||||
if let Err(e) =
|
||||
refresh_token_if_expired(db, workspace_id, token_var_path, &client.token).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.",
|
||||
resource_name, e
|
||||
);
|
||||
}
|
||||
Some(
|
||||
client
|
||||
.get_variable_value(token_var_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to resolve token variable {} for MCP resource {}: {}",
|
||||
token_var_path, resource_name, e
|
||||
))
|
||||
})?,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create new MCP client for this execution
|
||||
tracing::debug!("Creating fresh MCP client for {}", resource_name);
|
||||
let client = McpClient::from_resource(mcp_resource, db, workspace_id)
|
||||
let mcp_conn = McpClient::from_resource(mcp_resource, token)
|
||||
.await
|
||||
.context("Failed to create MCP client")?;
|
||||
|
||||
// Get raw MCP tools from client
|
||||
let raw_mcp_tools = client.available_tools();
|
||||
let raw_mcp_tools = mcp_conn.available_tools();
|
||||
|
||||
// Convert to Windmill Tool format
|
||||
let converted_tools =
|
||||
@@ -616,7 +638,7 @@ pub async fn load_mcp_tools(
|
||||
all_mcp_tools.extend(filtered_tools);
|
||||
|
||||
// Store client for later use and cleanup
|
||||
let mcp_client = Arc::new(client);
|
||||
let mcp_client = Arc::new(mcp_conn);
|
||||
mcp_clients.insert(resource_name, mcp_client);
|
||||
}
|
||||
|
||||
@@ -663,7 +685,7 @@ pub async fn load_mcp_tools<T>(
|
||||
_db: &DB,
|
||||
_workspace_id: &str,
|
||||
_mcp_configs: Vec<McpResourceConfig>,
|
||||
_auth_token: &str,
|
||||
_client: &windmill_common::client::AuthedClient,
|
||||
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
|
||||
Ok((HashMap::new(), Vec::new()))
|
||||
}
|
||||
|
||||
@@ -432,7 +432,7 @@ pub async fn handle_ai_agent_job(
|
||||
|
||||
let mcp_clients = if !mcp_configs.is_empty() {
|
||||
let (clients, mcp_tools) =
|
||||
load_mcp_tools(db, &job.workspace_id, mcp_configs, &client.token).await?;
|
||||
load_mcp_tools(db, &job.workspace_id, mcp_configs, client).await?;
|
||||
tools.extend(mcp_tools);
|
||||
clients
|
||||
} else {
|
||||
|
||||
@@ -40,9 +40,9 @@ use crate::handle_child::run_future_with_polling_update_job_poller;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
build_args_map, build_command_with_isolation, get_reserved_variables, read_file,
|
||||
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
|
||||
OccupancyMetrics, DEV_CONF_NSJAIL,
|
||||
build_args_map, build_command_with_isolation, get_reserved_variables, raw_to_string,
|
||||
read_file, read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
|
||||
start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL,
|
||||
},
|
||||
get_proxy_envs_for_lang,
|
||||
handle_child::handle_child,
|
||||
@@ -57,14 +57,6 @@ lazy_static::lazy_static! {
|
||||
pub static ref ANSI_ESCAPE_RE: Regex = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
|
||||
}
|
||||
|
||||
fn raw_to_string(x: &str) -> String {
|
||||
match serde_json::from_str::<serde_json::Value>(x) {
|
||||
Ok(serde_json::Value::String(x)) => x,
|
||||
Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
pub async fn handle_bash_job(
|
||||
mem_peak: &mut i32,
|
||||
@@ -84,6 +76,28 @@ pub async fn handle_bash_job(
|
||||
) -> Result<Box<RawValue>, Error> {
|
||||
let annotation = windmill_common::worker::BashAnnotations::parse(&content);
|
||||
|
||||
// `# sandbox <image>` selects the daemonless, nsjail-sandboxed container runtime
|
||||
// (extract the image's rootfs + run it inside the job's sandbox). A bare
|
||||
// `# sandbox` keeps the plain nsjail-bash modifier; `# docker` keeps v1 (dind).
|
||||
if let Some(image) = windmill_common::worker::BashAnnotations::sandbox_image(content) {
|
||||
return crate::docker_v2::handle_docker_v2_job(
|
||||
&image,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
job,
|
||||
conn,
|
||||
client,
|
||||
parent_runnable_path,
|
||||
content,
|
||||
job_dir,
|
||||
shared_mount,
|
||||
base_internal_url,
|
||||
worker_name,
|
||||
occupancy_metrics,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Check if sandbox annotation is used but nsjail is not available
|
||||
if annotation.sandbox && NSJAIL_AVAILABLE.is_none() {
|
||||
return Err(Error::ExecutionErr(
|
||||
|
||||
@@ -68,6 +68,16 @@ mount {
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub const DEV_CONF_NSJAIL: &str = "";
|
||||
|
||||
/// Turn a JSON value into the string a shell/CLI arg should receive: a JSON string
|
||||
/// becomes its inner value, anything else is re-serialized compactly.
|
||||
pub(crate) fn raw_to_string(x: &str) -> String {
|
||||
match serde_json::from_str::<serde_json::Value>(x) {
|
||||
Ok(serde_json::Value::String(x)) => x,
|
||||
Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_args_map<'a>(
|
||||
job: &'a MiniPulledJob,
|
||||
client: &AuthedClient,
|
||||
|
||||
@@ -56,9 +56,13 @@ const DOTNET_ROOT_DEFAULT: &str = "C:\\Program Files\\dotnet";
|
||||
#[cfg(unix)]
|
||||
const DOTNET_ROOT_DEFAULT: &str = "/usr/share/dotnet";
|
||||
|
||||
#[cfg(feature = "csharp")]
|
||||
const DOTNET_TARGET_FRAMEWORK_DEFAULT: &str = "net9.0";
|
||||
|
||||
#[cfg(feature = "csharp")]
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOTNET_ROOT: String = std::env::var("DOTNET_ROOT").unwrap_or_else(|_| DOTNET_ROOT_DEFAULT.to_string());
|
||||
static ref DOTNET_TARGET_FRAMEWORK: String = std::env::var("DOTNET_TARGET_FRAMEWORK").unwrap_or_else(|_| DOTNET_TARGET_FRAMEWORK_DEFAULT.to_string());
|
||||
}
|
||||
|
||||
#[cfg(feature = "csharp")]
|
||||
@@ -212,6 +216,7 @@ fn gen_cs_proj(
|
||||
)
|
||||
};
|
||||
|
||||
let target_framework = DOTNET_TARGET_FRAMEWORK.as_str();
|
||||
write_file(
|
||||
job_dir,
|
||||
"Main.csproj",
|
||||
@@ -219,7 +224,7 @@ fn gen_cs_proj(
|
||||
r#"<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>{target_framework}</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<StartupObject>WindmillScriptCSharpInternal.Wrapper</StartupObject>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
@@ -510,9 +515,10 @@ pub async fn handle_csharp_job(
|
||||
|
||||
let ws_suffix = crate::workspace_registry_cache_suffix(&job.workspace_id).await;
|
||||
let mut hash = calculate_hash(&format!(
|
||||
"{}{}",
|
||||
"{}{}{}",
|
||||
inner_content,
|
||||
requirements_o.unwrap_or(&String::new())
|
||||
requirements_o.unwrap_or(&String::new()),
|
||||
DOTNET_TARGET_FRAMEWORK.as_str()
|
||||
));
|
||||
hash.push_str(&ws_suffix);
|
||||
let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR);
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
//! Sandboxed container runtime: run a container as a sandboxed subprogram of the job.
|
||||
//!
|
||||
//! Unlike the legacy `# docker` (dind/daemon) path, this has no daemon and no Docker
|
||||
//! API. It splits *pull* from *run*:
|
||||
//!
|
||||
//! 1. **pull/extract** (`crane`, no daemon/store/root): materialize the image's root
|
||||
//! filesystem into `{job_dir}/rootfs` and read its OCI config
|
||||
//! (Env/Cmd/Entrypoint/WorkingDir), via a digest-keyed rootfs cache.
|
||||
//! 2. **run** (the job's own nsjail sandbox): execute the image command with the
|
||||
//! extracted rootfs bound in as the new root, so the container inherits exactly
|
||||
//! the job's confinement (filesystem mask, pid namespace, network, uid) and can't
|
||||
//! escape past what the job itself can reach.
|
||||
//!
|
||||
//! Selected by `# sandbox <image>` (a bare `# sandbox` keeps plain nsjail-bash;
|
||||
//! `# docker` keeps the v1 daemon path). The script body runs inside the image via
|
||||
//! `/bin/sh`; an empty body runs the image's ENTRYPOINT/CMD.
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, value::RawValue};
|
||||
use sqlx::types::Json;
|
||||
use tokio::process::Command;
|
||||
|
||||
use windmill_common::{client::AuthedClient, scripts::ScriptLang};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
worker::{to_raw_value, write_file, Connection},
|
||||
};
|
||||
|
||||
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
build_args_map, get_reserved_variables, raw_to_string, resolve_nsjail_timeout,
|
||||
resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL,
|
||||
},
|
||||
get_proxy_envs_for_lang,
|
||||
handle_child::handle_child,
|
||||
DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB,
|
||||
SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY,
|
||||
SANDBOX_REGISTRY_AUTH,
|
||||
};
|
||||
|
||||
const NSJAIL_CONFIG_RUN_DOCKER_CONTENT: &str = include_str!("../nsjail/run.docker.config.proto");
|
||||
|
||||
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// `crane` (google/go-containerregistry) — pulls + flattens an image to a rootfs
|
||||
/// without a daemon, store, root, or privileged container. We never *run* the
|
||||
/// image via crane (nsjail does the run), so a full container engine is overkill.
|
||||
pub static ref CRANE_PATH: String =
|
||||
std::env::var("CRANE_PATH").unwrap_or_else(|_| "crane".to_string());
|
||||
|
||||
/// `linux/<arch>` for the worker, pinned on every crane call so multi-arch images
|
||||
/// resolve deterministically (and `crane manifest` returns a real manifest, not an
|
||||
/// index).
|
||||
static ref CRANE_PLATFORM: String = format!("linux/{}", match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
other => other,
|
||||
});
|
||||
|
||||
/// Content-addressed cache of flattened rootfs tars, keyed by image digest. crane
|
||||
/// has no persistent store, so this is what gives cross-job dedup (and, since it's
|
||||
/// digest-keyed, automatic freshness when a moving tag changes).
|
||||
static ref ROOTFS_CACHE_DIR: String =
|
||||
format!("{}sandbox_rootfs", *windmill_common::worker::ROOT_CACHE_DIR);
|
||||
}
|
||||
|
||||
/// Guards against overlapping cache-eviction passes across concurrent jobs.
|
||||
static EVICTION_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// `sandbox_image_pull_policy` instance setting. With the digest-keyed cache, `newer`
|
||||
/// (default) re-resolves the digest each job (cheap manifest fetch) so moving tags
|
||||
/// like `:latest` stay fresh while unchanged digests reuse the cache. `missing` skips
|
||||
/// the registry when a digest is already cached for the ref; `never` only uses the
|
||||
/// cache (errors if absent); `always` == `newer` here.
|
||||
async fn pull_policy() -> String {
|
||||
let p = SANDBOX_IMAGE_PULL_POLICY.read().await.clone();
|
||||
match p.as_deref() {
|
||||
Some(p @ ("missing" | "newer" | "always" | "never")) => p.to_string(),
|
||||
_ => "newer".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `sandbox_image_max_size_mb` instance setting; 0 (or unset/non-positive) = no limit.
|
||||
async fn max_image_size_mb() -> u64 {
|
||||
SANDBOX_IMAGE_MAX_SIZE_MB.read().await.unwrap_or(0).max(0) as u64
|
||||
}
|
||||
|
||||
/// `sandbox_image_cache_max_mb` instance setting; 0 (or unset/non-positive) = unbounded.
|
||||
async fn image_cache_max_mb() -> u64 {
|
||||
SANDBOX_IMAGE_CACHE_MAX_MB.read().await.unwrap_or(0).max(0) as u64
|
||||
}
|
||||
|
||||
/// A ref is registry-qualified if the component before the first `/` looks like a
|
||||
/// host (contains `.` or `:`, or is `localhost`). Bare repos (`alpine`,
|
||||
/// `alpine:latest`, `myorg/img`) are unqualified and resolve against docker.io —
|
||||
/// or the configured default registry.
|
||||
fn registry_qualified(image: &str) -> bool {
|
||||
match image.split_once('/') {
|
||||
None => false,
|
||||
Some((first, _)) => first.contains('.') || first.contains(':') || first == "localhost",
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepend the `sandbox_image_default_registry` instance setting to unqualified image
|
||||
/// refs (fully-qualified refs are left untouched).
|
||||
async fn resolve_image_ref(image: &str) -> String {
|
||||
let registry = SANDBOX_IMAGE_DEFAULT_REGISTRY.read().await.clone();
|
||||
match registry {
|
||||
Some(registry) if !registry.trim().is_empty() && !registry_qualified(image) => {
|
||||
format!("{}/{}", registry.trim().trim_end_matches('/'), image)
|
||||
}
|
||||
_ => image.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// If the `sandbox_registry_auth` instance setting holds a docker `auth.json` blob,
|
||||
/// write it to a per-job `DOCKER_CONFIG` dir (`{job_dir}/.docker/config.json`, 0600,
|
||||
/// removed with the job) and return the dir to pass to crane via `DOCKER_CONFIG`.
|
||||
/// Returns `None` when unset. (docker `config.json` and podman `auth.json` share the
|
||||
/// `{"auths": {...}}` schema, so the same blob works.)
|
||||
async fn write_auth_dir(job_dir: &str) -> Result<Option<String>, Error> {
|
||||
let auth = SANDBOX_REGISTRY_AUTH.read().await.clone();
|
||||
let Some(auth) = auth.filter(|a| !a.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dir = format!("{job_dir}/.docker");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = format!("{dir}/config.json");
|
||||
// Create 0600 from the start (registry credentials) — no world-readable window.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.await?;
|
||||
f.write_all(auth.as_bytes()).await?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
tokio::fs::write(&path, auth).await?;
|
||||
Ok(Some(dir))
|
||||
}
|
||||
|
||||
/// The subset of an image's OCI config we apply to the run.
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
struct OciConfig {
|
||||
#[serde(default, rename = "Env")]
|
||||
env: Option<Vec<String>>,
|
||||
#[serde(default, rename = "Cmd")]
|
||||
cmd: Option<Vec<String>>,
|
||||
#[serde(default, rename = "Entrypoint")]
|
||||
entrypoint: Option<Vec<String>>,
|
||||
#[serde(default, rename = "WorkingDir")]
|
||||
working_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// Quote a string as a protobuf-text-format string literal for safe inclusion in
|
||||
/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets,
|
||||
/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or
|
||||
/// newline would otherwise let a hostile image config inject arbitrary nsjail
|
||||
/// directives and break out of the sandbox. Every byte is emitted as a printable
|
||||
/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit
|
||||
/// octal `\NNN` for control/non-ASCII bytes), so the result always parses.
|
||||
fn proto_str(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for &b in s.as_bytes() {
|
||||
match b {
|
||||
b'"' => out.push_str("\\\""),
|
||||
b'\\' => out.push_str("\\\\"),
|
||||
b'\n' => out.push_str("\\n"),
|
||||
b'\r' => out.push_str("\\r"),
|
||||
b'\t' => out.push_str("\\t"),
|
||||
0x20..=0x7e => out.push(b as char),
|
||||
_ => out.push_str(&format!("\\{b:03o}")),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Render container env vars as nsjail `envar:` directives (one per line). Each
|
||||
/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break
|
||||
/// the config nor reach nsjail's own process environment.
|
||||
fn render_envars(env: &[(String, String)]) -> String {
|
||||
env.iter()
|
||||
.map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}"))))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Run `crane` with the optional per-job `DOCKER_CONFIG` auth dir.
|
||||
async fn crane(args: &[&str], auth_dir: Option<&str>) -> Result<std::process::Output, Error> {
|
||||
let mut cmd = Command::new(CRANE_PATH.as_str());
|
||||
cmd.args(args);
|
||||
if let Some(dir) = auth_dir {
|
||||
cmd.env("DOCKER_CONFIG", dir);
|
||||
}
|
||||
cmd.output()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("failed to run crane {}: {e}", args.join(" "))))
|
||||
}
|
||||
|
||||
/// `crane config` output: the image config (Env/Cmd/Entrypoint/WorkingDir) is nested
|
||||
/// under the top-level `config` key.
|
||||
#[derive(Deserialize, Default)]
|
||||
struct CraneConfig {
|
||||
#[serde(default)]
|
||||
config: OciConfig,
|
||||
}
|
||||
|
||||
/// Filesystem-safe cache key for a digest (`sha256:ab..` -> `sha256_ab..`).
|
||||
fn digest_key(digest: &str) -> String {
|
||||
digest.replace([':', '/'], "_")
|
||||
}
|
||||
|
||||
/// Filesystem-safe, collision-resistant key for an image ref (the ref->digest file).
|
||||
fn ref_key(image: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
image.hash(&mut h);
|
||||
let safe: String = image
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let safe = &safe[safe.len().saturating_sub(80)..];
|
||||
format!("{safe}_{:016x}", h.finish())
|
||||
}
|
||||
|
||||
/// Resolve the image ref to a content digest, honoring the pull policy + a ref->digest
|
||||
/// cache. `missing`/`never` reuse a cached digest without hitting the registry (`never`
|
||||
/// errors if absent); `newer`/`always` always re-resolve via `crane digest`.
|
||||
async fn resolve_digest(
|
||||
image: &str,
|
||||
policy: &str,
|
||||
auth_dir: Option<&str>,
|
||||
) -> Result<String, Error> {
|
||||
let refs_dir = format!("{}/refs", *ROOTFS_CACHE_DIR);
|
||||
let ref_file = format!("{refs_dir}/{}", ref_key(image));
|
||||
|
||||
if matches!(policy, "missing" | "never") {
|
||||
if let Ok(d) = tokio::fs::read_to_string(&ref_file).await {
|
||||
let d = d.trim().to_string();
|
||||
if !d.is_empty()
|
||||
&& tokio::fs::metadata(format!("{}/{}.tar", *ROOTFS_CACHE_DIR, digest_key(&d)))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(d);
|
||||
}
|
||||
}
|
||||
if policy == "never" {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"image {image} is not in the sandbox cache and SANDBOX_IMAGE_PULL_POLICY=never"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let out = crane(&["digest", "--platform", &CRANE_PLATFORM, image], auth_dir).await?;
|
||||
if !out.status.success() {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"failed to resolve image {image}: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
)));
|
||||
}
|
||||
let digest = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = tokio::fs::create_dir_all(&refs_dir).await;
|
||||
// tmp+rename so a concurrent `missing`/`never` reader never sees a torn ref file.
|
||||
let ref_tmp = format!("{ref_file}.tmp.{}", digest_key(&digest));
|
||||
if tokio::fs::write(&ref_tmp, &digest).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&ref_tmp, &ref_file).await;
|
||||
}
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
/// Pull (if not cached) and unpack `image` into `{job_dir}/rootfs`, returning its OCI
|
||||
/// config. Uses `crane export`/`config` (no daemon/store/root) with a content-addressed
|
||||
/// rootfs+config cache keyed by digest for cross-job dedup.
|
||||
async fn extract_image(image: &str, job_dir: &str) -> Result<OciConfig, Error> {
|
||||
let rootfs = format!("{job_dir}/rootfs");
|
||||
tokio::fs::create_dir_all(&rootfs).await?;
|
||||
tokio::fs::create_dir_all(&*ROOTFS_CACHE_DIR).await?;
|
||||
|
||||
let auth_dir = write_auth_dir(job_dir).await?;
|
||||
let auth = auth_dir.as_deref();
|
||||
let digest = resolve_digest(image, &pull_policy().await, auth).await?;
|
||||
// Pin every subsequent fetch to the resolved digest, not the (mutable) tag, so the
|
||||
// content can't diverge from the digest we cache under if the tag moves mid-fetch.
|
||||
let pinned = format!("{}@{digest}", image.split('@').next().unwrap_or(image));
|
||||
let key = digest_key(&digest);
|
||||
let tar = format!("{}/{key}.tar", *ROOTFS_CACHE_DIR);
|
||||
let cfg = format!("{}/{key}.json", *ROOTFS_CACHE_DIR);
|
||||
let size_file = format!("{}/{key}.size", *ROOTFS_CACHE_DIR);
|
||||
let token = std::path::Path::new(job_dir)
|
||||
.file_name()
|
||||
.map(|x| x.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Enforce the size cap on EVERY job (not just cache misses), using a cached size so
|
||||
// a cache reuse needs no registry call — lowering the limit rejects cached images too.
|
||||
enforce_image_size_limit(&pinned, &size_file, auth).await?;
|
||||
|
||||
// Materialize the flattened rootfs. The cache tar can be evicted concurrently, so up
|
||||
// to two attempts: hardlink the cache tar into the job dir (pins the inode against
|
||||
// eviction) before extracting; if it vanished first, re-fetch.
|
||||
let job_tar = format!("{job_dir}/rootfs.tar");
|
||||
for attempt in 0..2 {
|
||||
if tokio::fs::metadata(&tar).await.is_err() {
|
||||
fetch_into_cache(&pinned, &tar, &cfg, &token, auth).await?;
|
||||
}
|
||||
let config = read_oci_config(&cfg).await;
|
||||
let _ = tokio::fs::remove_file(&job_tar).await;
|
||||
// Stage the cache tar into the job dir so concurrent eviction can't unlink it out
|
||||
// from under `tar -xf`. Prefer a hardlink (free), but the cache volume and the job
|
||||
// dir are usually on *different* filesystems in the shipped deployments (the cache
|
||||
// is its own volume/PVC) — there `hard_link` returns EXDEV, so fall back to a copy.
|
||||
// `copy` reads through the source inode, so an eviction mid-copy still completes.
|
||||
let staged = match tokio::fs::hard_link(&tar, &job_tar).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(e), // vanished — re-fetch
|
||||
Err(_) => tokio::fs::copy(&tar, &job_tar).await.map(|_| ()),
|
||||
};
|
||||
match staged {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempt == 0 => {
|
||||
continue; // evicted between the check and the staging — re-fetch
|
||||
}
|
||||
Err(e) => return Err(Error::ExecutionErr(format!("failed to stage rootfs: {e}"))),
|
||||
}
|
||||
// Extract as the worker user (rootfs is worker-owned → uid 0 inside the jail).
|
||||
let untar = Command::new("tar")
|
||||
.args(["-xf", &job_tar, "-C", &rootfs])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("failed to run tar: {e}")))?;
|
||||
let _ = tokio::fs::remove_file(&job_tar).await;
|
||||
if !untar.status.success() {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"failed to unpack image {image}: {}",
|
||||
String::from_utf8_lossy(&untar.stderr)
|
||||
)));
|
||||
}
|
||||
return Ok(config);
|
||||
}
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"failed to materialize rootfs for {image} (cache evicted twice)"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Fetch + flatten `pinned` (a `name@digest` ref) into the cache: export the rootfs tar
|
||||
/// and write the OCI config sidecar, both via tmp+rename so concurrent readers never see
|
||||
/// a torn file. The tar is published last (a present tar implies a present config).
|
||||
async fn fetch_into_cache(
|
||||
pinned: &str,
|
||||
tar: &str,
|
||||
cfg: &str,
|
||||
token: &str,
|
||||
auth: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let tar_tmp = format!("{tar}.tmp.{token}");
|
||||
let cfg_tmp = format!("{cfg}.tmp.{token}");
|
||||
let exported = crane(
|
||||
&["export", "--platform", &CRANE_PLATFORM, pinned, &tar_tmp],
|
||||
auth,
|
||||
)
|
||||
.await?;
|
||||
if !exported.status.success() {
|
||||
let _ = tokio::fs::remove_file(&tar_tmp).await;
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"failed to export image {pinned}: {}",
|
||||
String::from_utf8_lossy(&exported.stderr)
|
||||
)));
|
||||
}
|
||||
let config = crane(&["config", "--platform", &CRANE_PLATFORM, pinned], auth).await?;
|
||||
if !config.status.success() {
|
||||
let _ = tokio::fs::remove_file(&tar_tmp).await;
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"failed to read image {pinned} config: {}",
|
||||
String::from_utf8_lossy(&config.stderr)
|
||||
)));
|
||||
}
|
||||
let _ = tokio::fs::write(&cfg_tmp, &config.stdout).await;
|
||||
let _ = tokio::fs::rename(&cfg_tmp, cfg).await;
|
||||
tokio::fs::rename(&tar_tmp, tar).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the cached OCI config (Env/Cmd/Entrypoint/WorkingDir); tolerate a missing or torn
|
||||
/// sidecar by falling back to defaults (the run still works off the body + image FS).
|
||||
async fn read_oci_config(cfg: &str) -> OciConfig {
|
||||
match tokio::fs::read(cfg).await {
|
||||
Ok(bytes) => serde_json::from_slice::<CraneConfig>(&bytes)
|
||||
.map(|c| c.config)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => OciConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Manifest descriptor (`crane manifest`), for the pre-download size guard.
|
||||
#[derive(Deserialize, Default)]
|
||||
struct CraneDescriptor {
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
#[derive(Deserialize, Default)]
|
||||
struct CraneManifest {
|
||||
#[serde(default)]
|
||||
layers: Vec<CraneDescriptor>,
|
||||
#[serde(default)]
|
||||
config: CraneDescriptor,
|
||||
}
|
||||
|
||||
/// Reject the image if its compressed download size exceeds `SANDBOX_IMAGE_MAX_SIZE_MB`.
|
||||
/// Runs on EVERY job (so lowering the limit rejects already-cached images too); the size
|
||||
/// is read from a `{digest}.size` sidecar when present (no registry call on cache reuse)
|
||||
/// and otherwise fetched once via `crane manifest` (before any layer download) and cached.
|
||||
/// No-op when the limit is 0 (unset).
|
||||
async fn enforce_image_size_limit(
|
||||
pinned: &str,
|
||||
size_file: &str,
|
||||
auth_dir: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let max = max_image_size_mb().await;
|
||||
if max == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = match tokio::fs::read_to_string(size_file)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
{
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let out = crane(
|
||||
&["manifest", "--platform", &CRANE_PLATFORM, pinned],
|
||||
auth_dir,
|
||||
)
|
||||
.await?;
|
||||
if !out.status.success() {
|
||||
// Don't silently bypass the guard — surface it so an operator can see the
|
||||
// size limit isn't being enforced for this image.
|
||||
tracing::warn!(
|
||||
"sandbox image size guard: `crane manifest {pinned}` failed, not enforcing \
|
||||
SANDBOX_IMAGE_MAX_SIZE_MB: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let manifest: CraneManifest = match serde_json::from_slice(&out.stdout) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"sandbox image size guard: cannot parse `crane manifest` json: {e}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let b = manifest.config.size + manifest.layers.iter().map(|l| l.size).sum::<u64>();
|
||||
let _ = tokio::fs::write(size_file, b.to_string()).await;
|
||||
b
|
||||
}
|
||||
};
|
||||
let mb = bytes / 1_000_000;
|
||||
if mb > max {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"image {pinned} is {mb} MB (compressed), over the SANDBOX_IMAGE_MAX_SIZE_MB limit of {max} MB"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort eviction: while the cached rootfs tars exceed `SANDBOX_IMAGE_CACHE_MAX_MB`,
|
||||
/// remove the oldest by mtime (creation order — tars are write-once, cache hits don't
|
||||
/// touch mtime). No-op when the limit is 0 (unset). Skipped if another pass is already
|
||||
/// running. The per-job extracted rootfs lives in the job dir (cleaned with the job), so
|
||||
/// only the content-addressed tar+config+size cache is pruned. Also sweeps orphaned
|
||||
/// `*.tmp.*` files left by a crashed mid-export.
|
||||
async fn enforce_image_cache_limit() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let max_mb = image_cache_max_mb().await;
|
||||
if max_mb == 0 {
|
||||
return;
|
||||
}
|
||||
if EVICTION_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Reset the guard on every exit path (incl. an early `break` or a panic), so a
|
||||
// stuck flag can never permanently disable eviction until a worker restart.
|
||||
struct ResetOnDrop;
|
||||
impl Drop for ResetOnDrop {
|
||||
fn drop(&mut self) {
|
||||
EVICTION_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _reset = ResetOnDrop;
|
||||
let max_bytes = max_mb.saturating_mul(1_000_000);
|
||||
|
||||
// (path, size, mtime) for every cached rootfs tar; also sweep orphaned tmp files.
|
||||
async fn list_tars() -> Vec<(std::path::PathBuf, u64, std::time::SystemTime)> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(mut rd) = tokio::fs::read_dir(&*ROOTFS_CACHE_DIR).await else {
|
||||
return out;
|
||||
};
|
||||
while let Ok(Some(e)) = rd.next_entry().await {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// Reclaim leftover `*.tmp.<token>` files from a crashed mid-export.
|
||||
if name.contains(".tmp.") {
|
||||
let _ = tokio::fs::remove_file(&p).await;
|
||||
continue;
|
||||
}
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("tar") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(m) = e.metadata().await {
|
||||
let mtime = m.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||
out.push((p, m.len(), mtime));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut tars = list_tars().await;
|
||||
let total: u64 = tars.iter().map(|(_, s, _)| *s).sum();
|
||||
if total <= max_bytes || tars.is_empty() {
|
||||
break;
|
||||
}
|
||||
tars.sort_by_key(|(_, _, mtime)| *mtime);
|
||||
let victim = tars[0].0.clone();
|
||||
if tokio::fs::remove_file(&victim).await.is_err() {
|
||||
break; // can't reclaim — stop rather than spin on the same victim
|
||||
}
|
||||
// Drop the sibling config + size sidecars too.
|
||||
let _ = tokio::fs::remove_file(victim.with_extension("json")).await;
|
||||
let _ = tokio::fs::remove_file(victim.with_extension("size")).await;
|
||||
tracing::info!("sandbox image cache eviction: removed {}", victim.display());
|
||||
}
|
||||
// `_reset` drops here and clears EVICTION_RUNNING.
|
||||
}
|
||||
|
||||
/// Build the nsjail mount block that binds each top-level entry of the rootfs in
|
||||
/// place. Binding the whole rootfs at `/` trips nsjail's read-only remount of its
|
||||
/// base root in a rootless userns; per-entry binds avoid it. `proc`, `dev`, `tmp`
|
||||
/// and `sys` are skipped — the profile provides them.
|
||||
async fn generate_rootfs_mounts(rootfs: &str) -> Result<String, Error> {
|
||||
let mut block = String::new();
|
||||
let mut entries = tokio::fs::read_dir(rootfs).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if matches!(name.as_ref(), "proc" | "dev" | "tmp" | "sys") {
|
||||
continue;
|
||||
}
|
||||
let src = proto_str(&format!("{rootfs}/{name}"));
|
||||
let dst = proto_str(&format!("/{name}"));
|
||||
let file_type = entry.file_type().await?;
|
||||
if file_type.is_symlink() {
|
||||
// Recreate top-level symlinks (e.g. usr-merged /bin -> usr/bin) as
|
||||
// symlinks in the jail. The target is image-controlled but only ever
|
||||
// *resolved inside the jail* (against the bound rootfs dirs / jail
|
||||
// pseudo-fs) — there is no host `/` in the jail for it to point at — and
|
||||
// it is escaped via proto_str, so it can neither escape nor inject config.
|
||||
let target = tokio::fs::read_link(entry.path())
|
||||
.await
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
block.push_str(&format!(
|
||||
"mount {{\n src: {}\n dst: {dst}\n is_symlink: true\n mandatory: false\n}}\n",
|
||||
proto_str(&target),
|
||||
));
|
||||
} else {
|
||||
block.push_str(&format!(
|
||||
"mount {{\n src: {src}\n dst: {dst}\n is_bind: true\n rw: true\n mandatory: false\n}}\n",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
pub async fn handle_docker_v2_job(
|
||||
image: &str,
|
||||
mem_peak: &mut i32,
|
||||
canceled_by: &mut Option<CanceledBy>,
|
||||
job: &MiniPulledJob,
|
||||
conn: &Connection,
|
||||
client: &AuthedClient,
|
||||
parent_runnable_path: Option<String>,
|
||||
content: &str,
|
||||
job_dir: &str,
|
||||
shared_mount: &str,
|
||||
base_internal_url: &str,
|
||||
worker_name: &str,
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
) -> Result<Box<RawValue>, Error> {
|
||||
// The sandboxed container runtime *is* nsjail, so it requires nsjail. (`# docker`
|
||||
// keeps the v1 dind path for non-sandboxed workers.)
|
||||
if NSJAIL_AVAILABLE.is_none() {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"`# sandbox {image}` runs the image inside nsjail, which is not available on \
|
||||
this worker. Install nsjail, or use a bare `# docker` (dind) instead."
|
||||
)));
|
||||
}
|
||||
|
||||
// Apply the default-registry instance setting to unqualified refs.
|
||||
let resolved_image = resolve_image_ref(image).await;
|
||||
let image = resolved_image.as_str();
|
||||
|
||||
append_logs(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"),
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
let config = extract_image(image, job_dir).await?;
|
||||
let rootfs = format!("{job_dir}/rootfs");
|
||||
|
||||
// Best-effort: keep the cached rootfs tars under their size cap (overlaps the run).
|
||||
tokio::spawn(enforce_image_cache_limit());
|
||||
|
||||
// Resolve the script args from the bash signature, like the bash executor.
|
||||
let args = build_args_map(job, client, conn).await?.map(Json);
|
||||
let job_args = if args.is_some() {
|
||||
args.as_ref()
|
||||
} else {
|
||||
job.args.as_ref()
|
||||
};
|
||||
let args_owned = windmill_parser_bash::parse_bash_sig(content)?
|
||||
.args
|
||||
.iter()
|
||||
.map(|arg| {
|
||||
job_args
|
||||
.and_then(|x| x.get(&arg.name).map(|x| raw_to_string(x.get())))
|
||||
.unwrap_or_else(String::new)
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
// The body is everything that isn't a leading `#` annotation/comment line. With
|
||||
// a body we run it via the image's `/bin/sh`; without one we run the image's
|
||||
// ENTRYPOINT + CMD.
|
||||
let has_body = content
|
||||
.lines()
|
||||
.any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'));
|
||||
|
||||
let cmd_args: Vec<String> = if has_body {
|
||||
// Pass the body straight to `sh -c` rather than writing a script file into
|
||||
// the image-controlled rootfs: a malicious image could plant that path as a
|
||||
// symlink to a host file and capture the worker's write before nsjail starts
|
||||
// (sandbox-boundary bypass). `sh -c <body> sh <args...>` binds args as $1.. .
|
||||
let mut v = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
format!("set -e\n{content}"),
|
||||
"sh".to_string(),
|
||||
];
|
||||
v.extend(args_owned.iter().cloned());
|
||||
v
|
||||
} else {
|
||||
let mut v = config.entrypoint.clone().unwrap_or_default();
|
||||
v.extend(config.cmd.clone().unwrap_or_default());
|
||||
if v.is_empty() {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"image {image} has no ENTRYPOINT/CMD and the script body is empty — \
|
||||
nothing to run"
|
||||
)));
|
||||
}
|
||||
v.extend(args_owned.iter().cloned());
|
||||
v
|
||||
};
|
||||
|
||||
let working_dir = config
|
||||
.working_dir
|
||||
.as_deref()
|
||||
.filter(|w| !w.is_empty())
|
||||
.unwrap_or("/");
|
||||
|
||||
// The image's OCI Env is attacker-controlled (BOTH keys and values), so it must
|
||||
// NOT enter the nsjail launcher's own process env: a hostile image could set
|
||||
// LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT and have the dynamic loader run code in
|
||||
// the nsjail binary as the worker — outside the jail — before it sandboxes.
|
||||
// Deliver it to the *child only* via proto-escaped `envar:` directives.
|
||||
let mut container_env: Vec<(String, String)> = Vec::new();
|
||||
for kv in config.env.unwrap_or_default() {
|
||||
if let Some((k, v)) = kv.split_once('=') {
|
||||
container_env.push((k.to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
if !container_env.iter().any(|(k, _)| k == "PATH") {
|
||||
container_env.push(("PATH".to_string(), DEFAULT_PATH.to_string()));
|
||||
}
|
||||
if !container_env.iter().any(|(k, _)| k == "HOME") {
|
||||
container_env.push(("HOME".to_string(), "/root".to_string()));
|
||||
}
|
||||
let envars = render_envars(&container_env);
|
||||
|
||||
// Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir.
|
||||
let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await;
|
||||
let rootfs_mounts = generate_rootfs_mounts(&rootfs).await?;
|
||||
write_file(
|
||||
job_dir,
|
||||
"run.docker.config.proto",
|
||||
&NSJAIL_CONFIG_RUN_DOCKER_CONTENT
|
||||
.replace("{TIMEOUT}", &nsjail_timeout)
|
||||
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
|
||||
// proto_str-quoted: WorkingDir is image-controlled, must not break out
|
||||
// of the `cwd:` string and inject nsjail directives.
|
||||
.replace("{WORKDIR}", &proto_str(working_dir))
|
||||
.replace("{ROOTFS_MOUNTS}", &rootfs_mounts)
|
||||
.replace(
|
||||
"{TMP_MOUNT_BLOCK}",
|
||||
&resolve_nsjail_tmp_mount_block(job_dir).await,
|
||||
)
|
||||
// `# volume` mounts + same-worker shared folder (empty if none).
|
||||
.replace("{SHARED_MOUNT}", shared_mount)
|
||||
// Image env as `envar:` directives (child-only), so it never touches
|
||||
// nsjail's process env.
|
||||
.replace("{ENVARS}", &envars)
|
||||
.replace("#{DEV}", DEV_CONF_NSJAIL),
|
||||
)?;
|
||||
|
||||
// nsjail's OWN process env: only windmill-trusted keys (reserved vars so
|
||||
// `wmill`/API calls work, + proxy). `keep_env: true` forwards these to the
|
||||
// child. The image env is NOT here — see container_env above.
|
||||
let mut reserved_variables =
|
||||
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
||||
reserved_variables.insert("RUST_LOG".to_string(), "info".to_string());
|
||||
reserved_variables.insert(
|
||||
"BASE_INTERNAL_URL".to_string(),
|
||||
base_internal_url.to_string(),
|
||||
);
|
||||
|
||||
let proxy_envs = get_proxy_envs_for_lang(
|
||||
&ScriptLang::Bash,
|
||||
job.kind,
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut nsjail_run_args = vec!["--config", "run.docker.config.proto", "--"];
|
||||
nsjail_run_args.extend(cmd_args.iter().map(|s| s.as_str()));
|
||||
|
||||
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
|
||||
nsjail_cmd
|
||||
.current_dir(job_dir)
|
||||
.env_clear()
|
||||
.envs(reserved_variables)
|
||||
.envs(proxy_envs)
|
||||
.args(nsjail_run_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let child = start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?;
|
||||
|
||||
handle_child(
|
||||
&job.id,
|
||||
conn,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
child,
|
||||
true,
|
||||
worker_name,
|
||||
&job.workspace_id,
|
||||
"sandboxed container run",
|
||||
job.timeout,
|
||||
true,
|
||||
&mut Some(occupancy_metrics),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(to_raw_value(&json!(format!(
|
||||
"sandboxed container ({image}) completed successfully"
|
||||
))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{digest_key, proto_str, ref_key, registry_qualified, render_envars};
|
||||
|
||||
#[test]
|
||||
fn digest_key_is_filesystem_safe() {
|
||||
assert_eq!(digest_key("sha256:4d889c14e7d5"), "sha256_4d889c14e7d5");
|
||||
// No `:` or `/` survives (both would break the cache filename).
|
||||
let k = digest_key("sha256:ab/cd:ef");
|
||||
assert!(!k.contains(':') && !k.contains('/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_key_is_safe_and_stable() {
|
||||
// Deterministic for a given ref...
|
||||
assert_eq!(ref_key("ghcr.io/o/i:tag"), ref_key("ghcr.io/o/i:tag"));
|
||||
// ...distinguishes different refs...
|
||||
assert_ne!(ref_key("alpine:latest"), ref_key("alpine:edge"));
|
||||
// ...and is filesystem-safe (no `/` or `:`), incl. for multibyte refs (no panic
|
||||
// on the trailing-80 byte slice since every char maps to single-byte ASCII).
|
||||
for r in [
|
||||
"alpine",
|
||||
"ghcr.io/o/i:tag",
|
||||
"localhost:5000/r@sha256:ab",
|
||||
"rég/imagé:tag",
|
||||
] {
|
||||
let k = ref_key(r);
|
||||
assert!(!k.contains('/') && !k.contains(':'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_envars_emits_proto_directives() {
|
||||
// Image-controlled env (incl. loader vars) is rendered as `envar:` directives
|
||||
// — i.e. delivered to the child via the config, NOT nsjail's process env, so
|
||||
// it can never set LD_PRELOAD/etc. on the nsjail binary itself.
|
||||
let env = vec![
|
||||
("PATH".to_string(), "/usr/bin".to_string()),
|
||||
("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()),
|
||||
];
|
||||
let out = render_envars(&env);
|
||||
assert_eq!(
|
||||
out,
|
||||
"envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\""
|
||||
);
|
||||
// A value trying to inject extra directives is escaped, not interpreted.
|
||||
let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())];
|
||||
let line = render_envars(&evil);
|
||||
assert!(line.starts_with("envar: \""));
|
||||
assert!(!line.contains("\nclone_newuser"));
|
||||
assert!(line.contains("\\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proto_str_escapes_injection() {
|
||||
// Normal paths are just wrapped in quotes.
|
||||
assert_eq!(proto_str("/app"), "\"/app\"");
|
||||
// A `"` is escaped so it cannot close the surrounding string and inject
|
||||
// subsequent nsjail directives — this is what the WorkingDir / mount-src
|
||||
// sandboxing fixes depend on.
|
||||
let malicious = "/x\"\nmount { src: \"/\" dst: \"/host\" is_bind: true }\n#";
|
||||
let escaped = proto_str(malicious);
|
||||
assert!(escaped.starts_with('"') && escaped.ends_with('"'));
|
||||
// No raw quote or newline survives inside the rendered literal.
|
||||
let inner = &escaped[1..escaped.len() - 1];
|
||||
assert!(!inner.contains('\n'));
|
||||
assert!(!inner.contains("\"") || inner.contains("\\\""));
|
||||
assert!(escaped.contains("\\\"")); // the inner quote is backslash-escaped
|
||||
assert!(escaped.contains("\\n")); // the newline is escaped
|
||||
// Control and non-ASCII bytes render as valid 3-digit octal escapes (never
|
||||
// a raw byte or an invalid `\u{..}` that nsjail's parser would reject).
|
||||
assert_eq!(proto_str("a\u{1b}b"), "\"a\\033b\""); // ESC (0x1b)
|
||||
assert_eq!(proto_str("é"), "\"\\303\\251\""); // UTF-8 bytes 0xc3 0xa9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_qualified_classifies_refs() {
|
||||
// Unqualified: bare repos (with/without tag) and docker.io org/repo.
|
||||
for img in ["alpine", "alpine:latest", "myorg/img", "myorg/img:1.2"] {
|
||||
assert!(!registry_qualified(img), "{img} should be unqualified");
|
||||
}
|
||||
// Qualified: the first path component is a host (has `.`/`:`) or localhost.
|
||||
for img in [
|
||||
"ghcr.io/org/img",
|
||||
"registry.example.com/img:tag",
|
||||
"localhost:5000/img",
|
||||
"localhost/img",
|
||||
"host:5000/a/b",
|
||||
] {
|
||||
assert!(registry_qualified(img), "{img} should be qualified");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod csharp_executor;
|
||||
mod dedicated_worker_ee;
|
||||
mod dedicated_worker_oss;
|
||||
mod deno_executor;
|
||||
mod docker_v2;
|
||||
#[cfg(feature = "duckdb")]
|
||||
mod duckdb_executor;
|
||||
mod global_cache;
|
||||
|
||||
@@ -7,3 +7,6 @@ use windmill_queue::MiniPulledJob;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn add_root_flow_job_to_otlp(_queued_job: &MiniPulledJob, _success: bool) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn set_job_span_parent(_span: &tracing::Span, _job: &MiniPulledJob, _rj: &uuid::Uuid) {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user