mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
merge
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.
|
||||
|
||||
@@ -1,5 +1,95 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+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"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token_prefix",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c0da3f1f2c55900dbdf92b16ebbfdb7b4cc11a648460f175e4f57d080a0005a5"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT tag, script_lang AS \"script_lang: ScriptLang\" FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "script_lang: ScriptLang",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
Generated
+158
-133
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.714.0"
|
||||
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.714.0"
|
||||
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; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; 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
|
||||
481ea7f28dc5af6b72390c82f494f34cb9809546
|
||||
|
||||
+32
-16
@@ -154,29 +154,34 @@
|
||||
"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"
|
||||
@@ -185,14 +190,25 @@
|
||||
"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"
|
||||
],
|
||||
"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.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6263,7 +6263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6275,7 +6275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6296,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6308,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6320,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6332,7 +6332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6344,7 +6344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6378,7 +6378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6411,7 +6411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6423,7 +6423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6437,7 +6437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6454,7 +6454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6467,7 +6467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6479,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6513,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6529,7 +6529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6561,7 +6561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.714.0"
|
||||
version = "1.719.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.714.0"
|
||||
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 {
|
||||
|
||||
+73
-20
@@ -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;
|
||||
@@ -1095,24 +1104,8 @@ struct TokenRow {
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
/// When updating this filter, also update:
|
||||
/// - `register_token_expiry_notification` in windmill-api-auth/src/lib.rs
|
||||
/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte
|
||||
fn is_user_token(label: Option<&str>) -> bool {
|
||||
match label {
|
||||
None => true,
|
||||
Some(l) => {
|
||||
l != "session"
|
||||
&& !l.starts_with("ephemeral")
|
||||
&& !l.starts_with("Ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) {
|
||||
if !is_user_token(token.label.as_deref()) {
|
||||
if !windmill_common::auth::is_user_token(token.label.as_deref()) {
|
||||
return;
|
||||
}
|
||||
let prefix = token.token_prefix.as_deref().unwrap_or("??????????");
|
||||
@@ -2049,6 +2042,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");
|
||||
}
|
||||
@@ -871,9 +871,6 @@ pub async fn create_token_internal(
|
||||
/// Insert a pending expiry notification row for user tokens that have an expiration.
|
||||
/// Stores the token_hash so the join in check_expiring_tokens works even when
|
||||
/// the plaintext token column is NULL (after hash migration).
|
||||
/// When updating this filter, also update:
|
||||
/// - `is_user_token` in src/monitor.rs
|
||||
/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte
|
||||
pub async fn register_token_expiry_notification(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
token_hash: &str,
|
||||
@@ -881,14 +878,8 @@ pub async fn register_token_expiry_notification(
|
||||
expiration: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) {
|
||||
let Some(expiration) = expiration else { return };
|
||||
if label == Some("session")
|
||||
|| label.is_some_and(|l| {
|
||||
l.starts_with("ephemeral")
|
||||
|| l.starts_with("Ephemeral")
|
||||
|| l == "debugger-token"
|
||||
|| l.starts_with("mcp-oauth-")
|
||||
})
|
||||
{
|
||||
// System tokens don't get expiry notifications.
|
||||
if !windmill_common::auth::is_user_token(label) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = sqlx::query!(
|
||||
|
||||
@@ -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::{
|
||||
@@ -108,9 +109,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",
|
||||
)
|
||||
@@ -119,6 +121,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))
|
||||
@@ -212,9 +215,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 rows = sqlx::query_as::<_, ListableFlow>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -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', '{}');
|
||||
@@ -259,12 +259,10 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// ===== Hub endpoints (require external network, expect 500 or 200) =====
|
||||
|
||||
// --- hub/list ---
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/flows/hub/list"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/list")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == 200 || resp.status() == 500,
|
||||
"hub/list: unexpected status {}",
|
||||
@@ -272,12 +270,10 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- hub/get ---
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/flows/hub/get/1"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/get/1")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == 200 || resp.status() == 500,
|
||||
"hub/get: unexpected status {}",
|
||||
@@ -286,3 +282,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(())
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ use windmill_native_triggers::{
|
||||
decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
|
||||
get_workspace_integration,
|
||||
google::{parse_stop_channel_params, should_renew_channel},
|
||||
store_native_trigger, store_workspace_integration, NativeTriggerConfig, OAuthConfig,
|
||||
ServiceName,
|
||||
require_native_integration_use, store_native_trigger, store_workspace_integration,
|
||||
NativeTriggerConfig, OAuthConfig, ServiceName,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -329,6 +329,26 @@ async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// 3. Channel Expiration Renewal — should_renew_channel
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_require_native_integration_use_blocks_operators() {
|
||||
// Regression: the integration *use* routes (calendar/drive/repo/event pickers)
|
||||
// must reject read-only operators, who cannot create native triggers and so
|
||||
// must not be able to drive the admin-configured integration's upstream API.
|
||||
let mut operator = test_authed();
|
||||
operator.is_admin = false;
|
||||
operator.is_operator = true;
|
||||
assert!(require_native_integration_use(&operator).is_err());
|
||||
|
||||
// A regular non-admin author (the population that configures triggers) is allowed.
|
||||
let mut author = test_authed();
|
||||
author.is_admin = false;
|
||||
author.is_operator = false;
|
||||
assert!(require_native_integration_use(&author).is_ok());
|
||||
|
||||
// Admins are allowed.
|
||||
assert!(require_native_integration_use(&test_authed()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_drive_channel_expired() {
|
||||
let config = json!({
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -463,3 +463,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,
|
||||
|
||||
@@ -242,6 +242,23 @@ async fn create_schedule(
|
||||
|
||||
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
|
||||
|
||||
// A git-sync/merge/create write into a fork never sets operational state:
|
||||
// force `enabled = false` so a cloned / synced / merged / UI-created schedule
|
||||
// can't fire alongside the parent's. The fork owner re-enables locally via
|
||||
// `setenabled`. Schedule analog of the trigger rule in
|
||||
// `windmill-trigger::handler::workspace_is_fork`; the read half (parent-value
|
||||
// substitution on fork export) lives in `workspaces_export.rs`. Read fork-ness
|
||||
// on the non-RLS `db` pool (like the other two sites) so the determination is
|
||||
// complete regardless of the caller's folder perms.
|
||||
let target_is_fork: bool = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id IS NOT NULL FROM workspace WHERE id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check schedule for error
|
||||
ScheduleType::from_str(&ns.schedule, ns.cron_version.as_deref(), true)?;
|
||||
|
||||
@@ -341,7 +358,12 @@ async fn create_schedule(
|
||||
// flows (CLI merge, UI merge, `wmill push` of a fork tarball) — which
|
||||
// either send the source's actual flag (create case) or omit `enabled`
|
||||
// entirely (update case, where `EditSchedule` lacks the field).
|
||||
ns.enabled.unwrap_or(true),
|
||||
// A write into a fork always lands disabled regardless of the request.
|
||||
if target_is_fork {
|
||||
false
|
||||
} else {
|
||||
ns.enabled.unwrap_or(true)
|
||||
},
|
||||
resolved_email,
|
||||
resolved_permissioned_as,
|
||||
ns.on_failure,
|
||||
@@ -413,7 +435,7 @@ async fn create_schedule(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if ns.enabled.unwrap_or(true) {
|
||||
if !target_is_fork && ns.enabled.unwrap_or(true) {
|
||||
tx = push_scheduled_job(&db, tx, &schedule, Some(&authed.clone().into()), None).await?
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -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::{
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
|
||||
@@ -277,6 +278,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",
|
||||
@@ -286,6 +288,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))
|
||||
@@ -440,9 +443,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 rows = sqlx::query_as::<_, ListableScript>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -2171,14 +2178,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,
|
||||
@@ -2200,23 +2257,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")
|
||||
@@ -2235,6 +2279,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>>();
|
||||
@@ -2267,8 +2357,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
|
||||
)
|
||||
@@ -2276,6 +2368,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: {:?})",
|
||||
@@ -2339,8 +2435,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);
|
||||
|
||||
@@ -139,6 +139,10 @@ pub fn global_service() -> Router {
|
||||
"/tokens/update_scopes/{token_prefix}",
|
||||
post(update_token_scopes),
|
||||
)
|
||||
.route(
|
||||
"/tokens/update_label/{token_prefix}",
|
||||
post(update_token_label),
|
||||
)
|
||||
.route("/tokens/list", get(list_tokens))
|
||||
.route("/tokens/impersonate", post(impersonate))
|
||||
.route("/usage", get(get_usage))
|
||||
@@ -2408,6 +2412,89 @@ async fn update_token_scopes(
|
||||
Ok(format!("updated scopes for token {prefix}"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateTokenLabelRequest {
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
async fn update_token_label(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
Path(token_prefix): Path<String>,
|
||||
Json(req): Json<UpdateTokenLabelRequest>,
|
||||
) -> Result<String> {
|
||||
// The new label must not collide with a system-token namespace (`session`,
|
||||
// `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are
|
||||
// load-bearing, and a user-set collision would orphan the token — hidden
|
||||
// from the UI (`isUserToken`) and rejected by the editability guard below —
|
||||
// while it still authenticates. (`is_user_token(None)` is true, so clearing
|
||||
// the label is allowed.)
|
||||
if !windmill_common::auth::is_user_token(req.label.as_deref()) {
|
||||
return Err(Error::BadRequest(
|
||||
"label collides with a reserved system-token namespace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Matches the `token.label VARCHAR(1000)` column — reject overlong labels with
|
||||
// a 400 rather than letting Postgres raise a 500.
|
||||
const MAX_TOKEN_LABEL_LEN: usize = 1000;
|
||||
if req
|
||||
.label
|
||||
.as_deref()
|
||||
.is_some_and(|l| l.chars().count() > MAX_TOKEN_LABEL_LEN)
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"label must be at most {MAX_TOKEN_LABEL_LEN} characters"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Only user-created tokens may be relabeled — system tokens carry the
|
||||
// load-bearing labels described above. This SQL mirrors the canonical
|
||||
// `windmill_common::auth::is_user_token`; keep the two in sync (note the
|
||||
// case-insensitive `ephemeral` match).
|
||||
let updated: Option<String> = sqlx::query_scalar!(
|
||||
"UPDATE token SET label = $1
|
||||
WHERE email = $2 AND token_prefix = $3
|
||||
AND (label IS NULL OR (
|
||||
label <> 'session'
|
||||
AND lower(label) NOT LIKE 'ephemeral%'
|
||||
AND label <> 'debugger-token'
|
||||
AND label NOT LIKE 'mcp-oauth-%'
|
||||
))
|
||||
RETURNING token_prefix",
|
||||
req.label.as_deref(),
|
||||
&authed.email,
|
||||
&token_prefix,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let prefix = updated.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"token {token_prefix} not found, not owned by user, or not editable"
|
||||
))
|
||||
})?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"users.token.update_label",
|
||||
ActionKind::Update,
|
||||
&"global",
|
||||
Some(&prefix),
|
||||
Some([("label", req.label.as_deref().unwrap_or(""))].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
windmill_api_auth::invalidate_token_from_cache(&prefix);
|
||||
|
||||
Ok(format!("updated label for token {prefix}"))
|
||||
}
|
||||
|
||||
async fn leave_workspace(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
|
||||
@@ -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.714.0
|
||||
version: 1.719.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -5120,6 +5120,37 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/users/tokens/update_label/{token_prefix}:
|
||||
post:
|
||||
summary: update label of an existing token (owner only)
|
||||
operationId: updateTokenLabel
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- name: token_prefix
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: new label (null or omitted = no label)
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
"200":
|
||||
description: label updated
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/users/tokens/list:
|
||||
get:
|
||||
summary: list token
|
||||
@@ -9318,6 +9349,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
|
||||
@@ -11794,6 +11852,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
|
||||
@@ -12029,6 +12097,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"
|
||||
@@ -12236,6 +12314,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
|
||||
@@ -19814,6 +19902,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
|
||||
@@ -21611,6 +21709,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:
|
||||
@@ -21646,6 +21746,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 {
|
||||
|
||||
@@ -125,10 +125,18 @@ pub fn is_none_or_false(val: &Option<bool>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the keys to strip from trigger/schedule serialization when the
|
||||
/// source workspace is a fork. Stripping these keys avoids propagating
|
||||
/// fork-local operational state (enabled flag, runtime listener identifiers)
|
||||
/// back to the parent workspace through the git-sync round-trip.
|
||||
/// A fork's git-sync export rewrites each trigger's `mode` (and each schedule's
|
||||
/// `enabled`) to the *parent* workspace's value, instead of emitting the fork's
|
||||
/// own (clone-disabled / locally-toggled) state. This keeps the fork's synced
|
||||
/// file byte-identical to the parent on the operational-state field, so a
|
||||
/// normal-git PR merge has nothing to resolve — no dropped `mode:` line, no
|
||||
/// flipped parent trigger. Fork-only paths (absent from the parent) keep the
|
||||
/// fork's own value: there's no parent state to defer to, so the trigger lands
|
||||
/// with whatever the fork creator set. The write half of the same rule lives in
|
||||
/// `windmill-trigger::handler::workspace_is_fork`.
|
||||
///
|
||||
/// Maps trigger `path` → parent `mode` (as the lowercase enum text that matches
|
||||
/// `TriggerMode`'s serde representation). Empty when not a fork.
|
||||
#[cfg(any(
|
||||
feature = "http_trigger",
|
||||
feature = "websocket",
|
||||
@@ -148,20 +156,78 @@ pub fn is_none_or_false(val: &Option<bool>) -> bool {
|
||||
feature = "private"
|
||||
)
|
||||
))]
|
||||
fn fork_trigger_ignore_keys(is_fork: bool) -> Option<Vec<&'static str>> {
|
||||
if is_fork {
|
||||
Some(vec!["mode", "enabled"])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
async fn fork_parent_trigger_modes(
|
||||
db: &DB,
|
||||
table_name: &str,
|
||||
parent_workspace_id: Option<&str>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let Some(parent) = parent_workspace_id else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
// Read the parent's rows on the non-RLS pool (like `workspace_is_fork`): the
|
||||
// substitution must be complete regardless of the exporter's folder perms,
|
||||
// otherwise a parent path the exporter can't read would fall back to the
|
||||
// fork's own value and silently re-introduce the divergence we're fixing.
|
||||
// No leak: only values for paths the fork already has (it's a clone) are used.
|
||||
// SAFETY: `table_name` is a compile-time `TriggerCrud::TABLE_NAME` constant.
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(&format!(
|
||||
"SELECT path, mode::text FROM {} WHERE workspace_id = $1",
|
||||
table_name
|
||||
))
|
||||
.bind(parent)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
|
||||
fn fork_schedule_ignore_keys(is_fork: bool) -> Option<Vec<&'static str>> {
|
||||
if is_fork {
|
||||
Some(vec!["enabled"])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
/// Build the `{ "mode": <parent value> }` override for a single trigger, or
|
||||
/// `None` (keep the fork's own value) when the path is fork-only.
|
||||
#[cfg(any(
|
||||
feature = "http_trigger",
|
||||
feature = "websocket",
|
||||
feature = "postgres_trigger",
|
||||
feature = "mqtt_trigger",
|
||||
feature = "native_trigger",
|
||||
all(
|
||||
feature = "enterprise",
|
||||
any(
|
||||
feature = "kafka",
|
||||
feature = "sqs_trigger",
|
||||
feature = "gcp_trigger",
|
||||
feature = "azure_trigger",
|
||||
feature = "nats",
|
||||
feature = "smtp",
|
||||
),
|
||||
feature = "private"
|
||||
)
|
||||
))]
|
||||
fn trigger_mode_override(
|
||||
parent_modes: &HashMap<String, String>,
|
||||
path: &str,
|
||||
) -> Option<serde_json::Map<String, Value>> {
|
||||
parent_modes.get(path).map(|mode| {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("mode".to_string(), Value::String(mode.clone()));
|
||||
o
|
||||
})
|
||||
}
|
||||
|
||||
/// Schedule analog of [`fork_parent_trigger_modes`]: maps schedule `path` →
|
||||
/// parent `enabled`. Empty when not a fork.
|
||||
async fn fork_parent_schedule_enabled(
|
||||
db: &DB,
|
||||
parent_workspace_id: Option<&str>,
|
||||
) -> Result<HashMap<String, bool>> {
|
||||
let Some(parent) = parent_workspace_id else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
// Non-RLS pool, same rationale as `fork_parent_trigger_modes`.
|
||||
let rows: Vec<(String, bool)> =
|
||||
sqlx::query_as("SELECT path, enabled FROM schedule WHERE workspace_id = $1")
|
||||
.bind(parent)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
|
||||
enum ArchiveImpl {
|
||||
@@ -260,6 +326,25 @@ pub fn to_string_without_metadata<T>(
|
||||
extra_perms: ExtraPermsBehavior,
|
||||
ignore_keys: Option<Vec<&str>>,
|
||||
) -> Result<String>
|
||||
where
|
||||
T: ?Sized + Serialize,
|
||||
{
|
||||
to_string_without_metadata_inner(value, extra_perms, ignore_keys, None)
|
||||
}
|
||||
|
||||
/// Like [`to_string_without_metadata`] but additionally lets the caller
|
||||
/// override top-level keys after stripping. Used for fork trigger/schedule
|
||||
/// exports, where `mode`/`enabled` is rewritten to the *parent* workspace's
|
||||
/// value so the fork's synced file is byte-identical to the parent on those
|
||||
/// fields — a clean 3-way git merge instead of a dropped line. See the write
|
||||
/// half of the rule in `windmill-trigger::handler::workspace_is_fork`.
|
||||
#[inline]
|
||||
pub fn to_string_without_metadata_inner<T>(
|
||||
value: &T,
|
||||
extra_perms: ExtraPermsBehavior,
|
||||
ignore_keys: Option<Vec<&str>>,
|
||||
overrides: Option<&serde_json::Map<String, Value>>,
|
||||
) -> Result<String>
|
||||
where
|
||||
T: ?Sized + Serialize,
|
||||
{
|
||||
@@ -324,6 +409,12 @@ where
|
||||
obj.remove("default_permissioned_as");
|
||||
}
|
||||
|
||||
if let Some(overrides) = overrides {
|
||||
for (k, v) in overrides {
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&obj).ok()
|
||||
})
|
||||
.flatten()
|
||||
@@ -504,18 +595,18 @@ pub(crate) async fn tarball_workspace(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Source-of-truth check for fork-ness: the workspace's parent_workspace_id
|
||||
// column. The wm-fork-* prefix is a creation-time naming convention that
|
||||
// could in principle drift (rename, manual SQL); the column is the
|
||||
// contract that matches what the conflict-warning gates read.
|
||||
let is_fork: bool = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id IS NOT NULL FROM workspace WHERE id = $1",
|
||||
&w_id
|
||||
// Source-of-truth for fork-ness: the workspace's parent_workspace_id column.
|
||||
// The wm-fork-* prefix is a creation-time naming convention that could in
|
||||
// principle drift (rename, manual SQL); the column is the contract that
|
||||
// matches what the conflict-warning gates read. The id is also the workspace
|
||||
// whose trigger `mode` / schedule `enabled` a fork export defers to.
|
||||
let parent_workspace_id: Option<String> = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
.flatten();
|
||||
|
||||
let tmp_dir = TempDir::new_in(&*WINDMILL_DIR)?;
|
||||
|
||||
@@ -799,12 +890,21 @@ pub(crate) async fn tarball_workspace(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let schedule_ignore_keys = fork_schedule_ignore_keys(is_fork);
|
||||
// For a fork, defer each schedule's `enabled` to the parent so the
|
||||
// synced file matches the parent and the merge doesn't flip it.
|
||||
let parent_enabled =
|
||||
fork_parent_schedule_enabled(&db, parent_workspace_id.as_deref()).await?;
|
||||
for schedule in schedules {
|
||||
let app_str = &to_string_without_metadata(
|
||||
let enabled_override = parent_enabled.get(&schedule.path).map(|enabled| {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("enabled".to_string(), Value::Bool(*enabled));
|
||||
o
|
||||
});
|
||||
let app_str = &to_string_without_metadata_inner(
|
||||
&schedule,
|
||||
ExtraPermsBehavior::Drop,
|
||||
schedule_ignore_keys.clone(),
|
||||
None,
|
||||
enabled_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -814,38 +914,25 @@ pub(crate) async fn tarball_workspace(
|
||||
}
|
||||
|
||||
if include_triggers.unwrap_or(false) {
|
||||
#[cfg(any(
|
||||
feature = "http_trigger",
|
||||
feature = "websocket",
|
||||
feature = "postgres_trigger",
|
||||
feature = "mqtt_trigger",
|
||||
feature = "native_trigger",
|
||||
all(
|
||||
feature = "enterprise",
|
||||
any(
|
||||
feature = "kafka",
|
||||
feature = "sqs_trigger",
|
||||
feature = "gcp_trigger",
|
||||
feature = "azure_trigger",
|
||||
feature = "nats",
|
||||
feature = "smtp",
|
||||
),
|
||||
feature = "private"
|
||||
)
|
||||
))]
|
||||
let trigger_ignore_keys = fork_trigger_ignore_keys(is_fork);
|
||||
|
||||
#[cfg(feature = "http_trigger")]
|
||||
{
|
||||
use crate::triggers::http::HttpTrigger;
|
||||
let handler = HttpTrigger;
|
||||
let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<HttpTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in http_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -862,12 +949,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::websocket::WebsocketTrigger;
|
||||
let handler = WebsocketTrigger;
|
||||
let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<WebsocketTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in websocket_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -884,12 +979,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::kafka::KafkaTrigger;
|
||||
let handler = KafkaTrigger;
|
||||
let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<KafkaTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in kafka_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -906,12 +1009,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::sqs::SqsTrigger;
|
||||
let handler = SqsTrigger;
|
||||
let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<SqsTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in sqs_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -928,12 +1039,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::gcp::GcpTrigger;
|
||||
let handler = GcpTrigger;
|
||||
let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<GcpTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in gcp_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -950,12 +1069,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::azure::AzureTrigger;
|
||||
let handler = AzureTrigger;
|
||||
let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<AzureTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in azure_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -972,12 +1099,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::nats::NatsTrigger;
|
||||
let handler = NatsTrigger;
|
||||
let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<NatsTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in nats_triggers {
|
||||
let trigger_str: &String = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str: &String = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -994,12 +1129,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::postgres::PostgresTrigger;
|
||||
let handler = PostgresTrigger;
|
||||
let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<PostgresTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in postgres_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -1016,12 +1159,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::mqtt::MqttTrigger;
|
||||
let handler = MqttTrigger;
|
||||
let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<MqttTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in mqtt_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -1038,12 +1189,20 @@ pub(crate) async fn tarball_workspace(
|
||||
use crate::triggers::email::EmailTrigger;
|
||||
let handler = EmailTrigger;
|
||||
let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
|
||||
let parent_modes = fork_parent_trigger_modes(
|
||||
&db,
|
||||
<EmailTrigger as TriggerCrud>::TABLE_NAME,
|
||||
parent_workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for trigger in email_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
let mode_override = trigger_mode_override(&parent_modes, &trigger.base.path);
|
||||
let trigger_str = &to_string_without_metadata_inner(
|
||||
&trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
trigger_ignore_keys.clone(),
|
||||
None,
|
||||
mode_override.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
@@ -1065,10 +1224,12 @@ pub(crate) async fn tarball_workspace(
|
||||
list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None)
|
||||
.await?;
|
||||
|
||||
let mut native_ignore_keys = vec!["webhook_token_hash"];
|
||||
if let Some(ref extra) = trigger_ignore_keys {
|
||||
native_ignore_keys.extend_from_slice(extra);
|
||||
}
|
||||
// Native triggers (Nextcloud, Google Drive, GitHub) are never
|
||||
// cloned into a fork — a fork only has one if its owner created
|
||||
// it there, so it's always "fork-only" and keeps its own mode.
|
||||
// No parent-value substitution applies; we only strip the
|
||||
// webhook token hash.
|
||||
let native_ignore_keys = vec!["webhook_token_hash"];
|
||||
|
||||
for trigger in native_triggers {
|
||||
let trigger_str = &to_string_without_metadata(
|
||||
@@ -1359,3 +1520,67 @@ pub(crate) async fn tarball_workspace(
|
||||
];
|
||||
Ok((headers, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fork_export_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A fork export rewrites `mode` to the parent's value: the serialized file
|
||||
/// carries the parent's state (`enabled`), not the fork's clone-disabled DB
|
||||
/// value — so a normal-git merge sees no change on that line.
|
||||
#[test]
|
||||
fn override_substitutes_parent_mode() {
|
||||
let fork_trigger = json!({
|
||||
"path": "f/triggers/x",
|
||||
"script_path": "f/scripts/x",
|
||||
"mode": "disabled", // fork's local (clone-disabled) state
|
||||
"is_flow": false,
|
||||
});
|
||||
let mut overrides = serde_json::Map::new();
|
||||
overrides.insert("mode".to_string(), Value::String("enabled".to_string()));
|
||||
|
||||
let out = to_string_without_metadata_inner(
|
||||
&fork_trigger,
|
||||
ExtraPermsBehavior::Drop,
|
||||
None,
|
||||
Some(&overrides),
|
||||
)
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&out).unwrap();
|
||||
|
||||
assert_eq!(parsed["mode"], json!("enabled"), "parent mode substituted");
|
||||
// `path` is in the metadata strip list, so it should be removed.
|
||||
assert!(parsed.get("path").is_none());
|
||||
}
|
||||
|
||||
/// A fork-only trigger (no parent counterpart, so no override) keeps the
|
||||
/// fork creator's chosen state.
|
||||
#[test]
|
||||
fn no_override_keeps_fork_value() {
|
||||
let fork_only = json!({ "mode": "enabled", "script_path": "f/scripts/x" });
|
||||
let out =
|
||||
to_string_without_metadata_inner(&fork_only, ExtraPermsBehavior::Drop, None, None)
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(parsed["mode"], json!("enabled"));
|
||||
}
|
||||
|
||||
/// `trigger_mode_override` builds an override only when the parent has the
|
||||
/// path; fork-only paths return `None` (keep the fork's own value).
|
||||
#[cfg(feature = "http_trigger")]
|
||||
#[test]
|
||||
fn trigger_mode_override_defers_to_parent_or_self() {
|
||||
let mut parent_modes = HashMap::new();
|
||||
parent_modes.insert("f/triggers/shared".to_string(), "enabled".to_string());
|
||||
|
||||
let shared = trigger_mode_override(&parent_modes, "f/triggers/shared");
|
||||
assert_eq!(
|
||||
shared.as_ref().and_then(|o| o.get("mode")),
|
||||
Some(&Value::String("enabled".to_string())),
|
||||
);
|
||||
|
||||
// Fork-only path: no parent entry → no override → keep fork's own value.
|
||||
assert!(trigger_mode_override(&parent_modes, "f/triggers/fork_only").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,31 @@ use crate::{
|
||||
DB,
|
||||
};
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
|
||||
/// notifications and username overrides all key off them — so they must not be
|
||||
/// user-editable. `None` (no label) is treated as a user token.
|
||||
///
|
||||
/// This is the canonical copy. When updating it, also update its mirrors:
|
||||
/// - the `update_token_label` editability guard (SQL `WHERE`) in
|
||||
/// windmill-api-users/src/users.rs
|
||||
/// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte
|
||||
pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
match label {
|
||||
None => true,
|
||||
Some(l) => {
|
||||
// `ephemeral` is matched case-insensitively to agree exactly with the
|
||||
// frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and
|
||||
// the SQL `lower(label) NOT LIKE 'ephemeral%'` guard.
|
||||
l != "session"
|
||||
&& !l.to_lowercase().starts_with("ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a raw token using SHA-256 (hex-encoded, 64 chars).
|
||||
/// Used to store and look up tokens without keeping plaintext in the DB.
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
@@ -641,3 +666,35 @@ pub mod aws {
|
||||
Ok(assume_role_with_web_identity_fluent_builder)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_user_token;
|
||||
|
||||
#[test]
|
||||
fn user_tokens_are_editable() {
|
||||
assert!(is_user_token(None)); // no label
|
||||
assert!(is_user_token(Some("")));
|
||||
assert!(is_user_token(Some("my-ci-token")));
|
||||
assert!(is_user_token(Some("webhook-foo"))); // username-override prefix, not a system kind here
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_tokens_are_not_editable() {
|
||||
assert!(!is_user_token(Some("session")));
|
||||
assert!(!is_user_token(Some("ephemeral-script")));
|
||||
assert!(!is_user_token(Some("ephemeral-webhook-x")));
|
||||
assert!(!is_user_token(Some("Ephemeral lsp token")));
|
||||
assert!(!is_user_token(Some("debugger-token")));
|
||||
assert!(!is_user_token(Some("mcp-oauth-client")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_match_is_case_insensitive() {
|
||||
// Must agree with the frontend mirror (`toLowerCase().startsWith('ephemeral')`)
|
||||
// so a token can't be relabeled to a casing the backend allows but the UI hides.
|
||||
assert!(!is_user_token(Some("Ephemeral-test")));
|
||||
assert!(!is_user_token(Some("ePhemeral-test")));
|
||||
assert!(!is_user_token(Some("EPHEMERAL-test")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub const ALLOW_PRIVATE_MCP_SERVER_URLS_ENV: &str = "ALLOW_PRIVATE_MCP_SERVER_URLS";
|
||||
|
||||
/// Why a URL failed SSRF validation.
|
||||
///
|
||||
/// The distinction matters for callers that gate private endpoints behind a
|
||||
@@ -116,6 +118,49 @@ pub async fn validate_url_for_ssrf(url: &str) -> Result<(), SsrfValidationError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn allow_private_mcp_server_urls() -> bool {
|
||||
std::env::var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV)
|
||||
.ok()
|
||||
.is_some_and(|v| v == "true" || v == "1")
|
||||
}
|
||||
|
||||
pub async fn validate_mcp_server_url(url: &str) -> Result<(), SsrfValidationError> {
|
||||
let parsed =
|
||||
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
|
||||
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())),
|
||||
}
|
||||
|
||||
parsed.host_str().ok_or(SsrfValidationError::MissingHost)?;
|
||||
|
||||
if allow_private_mcp_server_urls() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_url_for_ssrf(url).await
|
||||
}
|
||||
|
||||
pub async fn validate_mcp_server_url_for_bad_request(url: &str, label: &str) -> Result<(), Error> {
|
||||
validate_mcp_server_url(url).await.map_err(|e| {
|
||||
Error::BadRequest(format!(
|
||||
"{label} is not allowed: {}",
|
||||
mcp_ssrf_error_message(&e)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mcp_ssrf_error_message(e: &SsrfValidationError) -> String {
|
||||
match e {
|
||||
SsrfValidationError::Private { .. } => format!(
|
||||
"{e}. If you need to use private/internal MCP server URLs, \
|
||||
set the {ALLOW_PRIVATE_MCP_SERVER_URLS_ENV}=true environment variable"
|
||||
),
|
||||
_ => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ipv4) => is_private_ipv4(ipv4),
|
||||
@@ -152,6 +197,32 @@ fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static TEST_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
struct PrivateMcpServerUrlsEnvGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl PrivateMcpServerUrlsEnvGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let previous = std::env::var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV).ok();
|
||||
match value {
|
||||
Some(value) => std::env::set_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV, value),
|
||||
None => std::env::remove_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV),
|
||||
}
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrivateMcpServerUrlsEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV, value),
|
||||
None => std::env::remove_var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_ipv4() {
|
||||
assert!(is_private_ipv4(&"127.0.0.1".parse().unwrap()));
|
||||
@@ -227,4 +298,66 @@ mod tests {
|
||||
Err(SsrfValidationError::Private { resolved: false })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_mcp_server_url_blocks_private_by_default() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateMcpServerUrlsEnvGuard::set(None);
|
||||
|
||||
assert!(matches!(
|
||||
validate_mcp_server_url("http://127.0.0.1/foo").await,
|
||||
Err(SsrfValidationError::Private { resolved: false })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_mcp_server_url_allows_private_when_env_is_enabled() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("true"));
|
||||
|
||||
assert!(validate_mcp_server_url("http://127.0.0.1/foo")
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_mcp_server_url_allows_private_when_env_is_one() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("1"));
|
||||
|
||||
assert!(validate_mcp_server_url("http://10.0.0.1/foo").await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_mcp_server_url_keeps_syntax_guards_when_private_urls_are_allowed() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("true"));
|
||||
|
||||
assert!(matches!(
|
||||
validate_mcp_server_url("localhost:11434/v1").await,
|
||||
Err(SsrfValidationError::DisallowedScheme(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_mcp_server_url("file:///tmp/socket").await,
|
||||
Err(SsrfValidationError::DisallowedScheme(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_mcp_error_message_includes_env_hint_only_for_private_urls() {
|
||||
let _lock = TEST_ENV_LOCK.lock().await;
|
||||
let _guard = PrivateMcpServerUrlsEnvGuard::set(None);
|
||||
|
||||
let private_error = validate_mcp_server_url("http://127.0.0.1/foo")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
mcp_ssrf_error_message(&private_error).contains("ALLOW_PRIVATE_MCP_SERVER_URLS=true")
|
||||
);
|
||||
|
||||
let invalid_error = validate_mcp_server_url("localhost:11434/v1")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!mcp_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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,34 @@ 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_mcp_server_url(&resource.url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"MCP server URL is not allowed: {}",
|
||||
windmill_common::ssrf::mcp_ssrf_error_message(&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 +78,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 +230,61 @@ impl McpClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct PrivateMcpServerUrlsEnvGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl PrivateMcpServerUrlsEnvGuard {
|
||||
fn unset() -> Self {
|
||||
let previous =
|
||||
std::env::var(windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV).ok();
|
||||
std::env::remove_var(windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV);
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrivateMcpServerUrlsEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(
|
||||
windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV,
|
||||
value,
|
||||
),
|
||||
None => {
|
||||
std::env::remove_var(windmill_common::ssrf::ALLOW_PRIVATE_MCP_SERVER_URLS_ENV)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 _guard = PrivateMcpServerUrlsEnvGuard::unset();
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use windmill_common::db::DB;
|
||||
use windmill_common::error;
|
||||
use windmill_common::variables::{build_crypt, decrypt, encrypt};
|
||||
|
||||
use crate::oauth::AuthorizationManager;
|
||||
use crate::oauth::{no_redirect_http_client, AuthorizationManager};
|
||||
|
||||
/// MCP client credentials returned by [`get_or_refresh_mcp_client`].
|
||||
pub struct McpClientCredentials {
|
||||
@@ -77,7 +77,14 @@ async fn register_client(
|
||||
redirect_uri: &str,
|
||||
client_name: &str,
|
||||
) -> Result<DcrResponse, error::Error> {
|
||||
let client = reqwest::Client::new();
|
||||
windmill_common::ssrf::validate_mcp_server_url_for_bad_request(
|
||||
registration_endpoint,
|
||||
"MCP server registration endpoint URL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = no_redirect_http_client()
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to build DCR client: {e}")))?;
|
||||
let request = DcrRequest {
|
||||
client_name: client_name.to_string(),
|
||||
redirect_uris: vec![redirect_uri.to_string()],
|
||||
@@ -121,6 +128,12 @@ pub async fn get_or_refresh_mcp_client(
|
||||
let base_url = (**windmill_common::BASE_URL.load()).clone();
|
||||
let redirect_uri = format!("{}/api/mcp/oauth/callback", base_url);
|
||||
|
||||
windmill_common::ssrf::validate_mcp_server_url_for_bad_request(
|
||||
mcp_server_url,
|
||||
"MCP server URL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cached_client: Option<McpOAuthClient> =
|
||||
sqlx::query_as("SELECT mcp_server_url, client_id, client_secret, client_secret_expires_at, token_endpoint FROM mcp_oauth_client WHERE mcp_server_url = $1")
|
||||
.bind(mcp_server_url)
|
||||
@@ -131,6 +144,11 @@ pub async fn get_or_refresh_mcp_client(
|
||||
if let Some(client) = cached_client {
|
||||
if !client.is_expired() {
|
||||
tracing::debug!("Using cached MCP client for {}", mcp_server_url);
|
||||
windmill_common::ssrf::validate_mcp_server_url_for_bad_request(
|
||||
&client.token_endpoint,
|
||||
"MCP server token endpoint URL",
|
||||
)
|
||||
.await?;
|
||||
let decrypted_secret = if let Some(ref encrypted_secret) = client.client_secret {
|
||||
Some(decrypt_client_secret(db, encrypted_secret).await?)
|
||||
} else {
|
||||
@@ -145,17 +163,27 @@ pub async fn get_or_refresh_mcp_client(
|
||||
tracing::debug!("Cached MCP client expired, re-registering");
|
||||
}
|
||||
|
||||
windmill_common::ssrf::validate_url_for_ssrf(mcp_server_url).await?;
|
||||
|
||||
let manager = AuthorizationManager::new(mcp_server_url)
|
||||
let mut manager = AuthorizationManager::new(mcp_server_url)
|
||||
.await
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to create auth manager: {e}")))?;
|
||||
let discovery_client = no_redirect_http_client().map_err(|e| {
|
||||
error::Error::BadRequest(format!("Failed to build MCP OAuth discovery client: {e}"))
|
||||
})?;
|
||||
manager
|
||||
.with_client(discovery_client)
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to configure auth manager: {e}")))?;
|
||||
|
||||
let metadata = manager
|
||||
.discover_metadata()
|
||||
.await
|
||||
.map_err(|e| error::Error::BadRequest(format!("OAuth discovery failed: {e}")))?;
|
||||
|
||||
windmill_common::ssrf::validate_mcp_server_url_for_bad_request(
|
||||
&metadata.token_endpoint,
|
||||
"MCP server token endpoint URL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let supports_dynamic_registration = metadata.registration_endpoint.is_some();
|
||||
|
||||
let (client_id, client_secret, expires_at) = if supports_dynamic_registration {
|
||||
|
||||
@@ -38,11 +38,70 @@ pub mod client_registration;
|
||||
pub mod oauth {
|
||||
//! Re-exports of rmcp auth and oauth2 types for MCP OAuth implementations
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
pub use rmcp::transport::auth::AuthorizationManager;
|
||||
|
||||
const DEFAULT_OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn no_redirect_http_client() -> Result<reqwest::Client, reqwest::Error> {
|
||||
no_redirect_http_client_with_timeout(DEFAULT_OAUTH_HTTP_TIMEOUT)
|
||||
}
|
||||
|
||||
pub(crate) fn no_redirect_http_client_with_timeout(
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Client, reqwest::Error> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
}
|
||||
|
||||
// Re-export oauth2 types needed for MCP OAuth flow
|
||||
pub use oauth2::{
|
||||
basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
|
||||
RedirectUrl, Scope, TokenUrl,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
io::Read,
|
||||
net::TcpListener,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_redirect_http_client_times_out_stalled_responses() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
|
||||
let mut buffer = [0; 1024];
|
||||
let _ = stream.read(&mut buffer);
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
|
||||
let client = no_redirect_http_client_with_timeout(Duration::from_millis(50)).unwrap();
|
||||
let started = Instant::now();
|
||||
let err = client
|
||||
.get(format!("http://{addr}/stall"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("stalled response should time out");
|
||||
|
||||
assert!(err.is_timeout(), "expected timeout error, got: {err}");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"stalled request should fail promptly"
|
||||
);
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{extract::Path, routing::get, Extension, Json, Router};
|
||||
use http::Method;
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{error::JsonResult, DB};
|
||||
|
||||
use crate::{get_workspace_integration, External, ServiceName};
|
||||
use crate::{get_workspace_integration, require_native_integration_use, External, ServiceName};
|
||||
|
||||
use super::{GitHub, GithubApiRepoResponse, GithubRepoEntry};
|
||||
|
||||
@@ -12,10 +13,12 @@ const PER_PAGE: usize = 100;
|
||||
const MAX_PAGES: usize = 10;
|
||||
|
||||
async fn list_repos(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<GitHub>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<GithubRepoEntry>> {
|
||||
require_native_integration_use(&authed)?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?;
|
||||
|
||||
let mut all_entries = Vec::new();
|
||||
|
||||
@@ -7,9 +7,10 @@ use axum::{
|
||||
};
|
||||
use http::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{error::JsonResult, DB};
|
||||
|
||||
use crate::{get_workspace_integration, External, ServiceName};
|
||||
use crate::{get_workspace_integration, require_native_integration_use, External, ServiceName};
|
||||
|
||||
use super::Google;
|
||||
|
||||
@@ -84,10 +85,12 @@ pub struct DriveFilesQuery {
|
||||
}
|
||||
|
||||
async fn list_calendars(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<GoogleCalendarEntry>> {
|
||||
require_native_integration_use(&authed)?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
|
||||
let url = format!(
|
||||
@@ -113,11 +116,13 @@ async fn list_calendars(
|
||||
}
|
||||
|
||||
async fn list_drive_files(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<DriveFilesQuery>,
|
||||
) -> JsonResult<GoogleDriveFilesResponse> {
|
||||
require_native_integration_use(&authed)?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
|
||||
let drive_query = if query.shared_with_me {
|
||||
@@ -186,10 +191,12 @@ struct SharedDriveApiEntry {
|
||||
}
|
||||
|
||||
async fn list_shared_drives(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<SharedDriveEntry>> {
|
||||
require_native_integration_use(&authed)?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
|
||||
let url = format!(
|
||||
|
||||
@@ -1226,6 +1226,20 @@ pub async fn store_workspace_integration(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorization gate for the integration *use* routes (calendar/drive/repo/event
|
||||
/// pickers). A workspace admin configures the integration, but any member who can
|
||||
/// create a native trigger needs the pickers to configure one. Operators are
|
||||
/// read-only and cannot create triggers, so they must not be able to drive the
|
||||
/// admin-configured integration's upstream API and enumerate its data.
|
||||
pub fn require_native_integration_use(authed: &ApiAuthed) -> Result<()> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot use workspace integrations".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
db: E,
|
||||
workspace_id: &str,
|
||||
|
||||
@@ -7,17 +7,21 @@ use windmill_common::{
|
||||
DB,
|
||||
};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
|
||||
use crate::{
|
||||
get_workspace_integration,
|
||||
nextcloud::{NextCloudEventType, OcsResponse},
|
||||
External, ServiceName,
|
||||
require_native_integration_use, External, ServiceName,
|
||||
};
|
||||
|
||||
async fn list_available_events<T: External>(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<T>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<NextCloudEventType>> {
|
||||
require_native_integration_use(&authed)?;
|
||||
let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?;
|
||||
|
||||
let base_url = integration
|
||||
|
||||
@@ -26,7 +26,6 @@ use windmill_common::error::{self, to_anyhow, Error};
|
||||
use windmill_common::more_serde::maybe_number_opt;
|
||||
use windmill_common::oauth2::*;
|
||||
use windmill_common::utils::now_from_db;
|
||||
use windmill_common::variables::{build_crypt, encrypt};
|
||||
use windmill_common::BASE_URL;
|
||||
|
||||
pub type DB = sqlx::Pool<sqlx::Postgres>;
|
||||
@@ -92,6 +91,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 +111,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
|
||||
@@ -482,11 +524,14 @@ pub struct OAuthAccountInfo {
|
||||
pub scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Refresh an OAuth token and update the database.
|
||||
/// Refresh an OAuth token and update the `account` row.
|
||||
/// Fetches the account from DB, then delegates to `refresh_token_for_account`.
|
||||
///
|
||||
/// Returns the freshly minted access token. Persisting it to the secret variable
|
||||
/// backing the resource is the caller's responsibility (it must route through the
|
||||
/// configured secret backend — see `store_oauth_token_value` in `windmill-store`).
|
||||
pub async fn refresh_token<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
id: i32,
|
||||
db: &DB,
|
||||
@@ -506,7 +551,6 @@ pub async fn refresh_token<'c>(
|
||||
|
||||
refresh_token_for_account(
|
||||
tx,
|
||||
path,
|
||||
w_id,
|
||||
id,
|
||||
db,
|
||||
@@ -519,9 +563,14 @@ pub async fn refresh_token<'c>(
|
||||
}
|
||||
|
||||
/// Refresh an OAuth token given pre-fetched account info (no additional SELECT).
|
||||
///
|
||||
/// Exchanges the refresh token, updates the `account` row (`refresh_token`,
|
||||
/// `expires_at`, `refresh_error`) and returns the new access token. It does NOT
|
||||
/// persist the token to the secret variable — the caller must do that through the
|
||||
/// configured secret backend (`store_oauth_token_value`), otherwise an external
|
||||
/// secret backend would keep serving the stale connect-time token.
|
||||
pub async fn refresh_token_for_account<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
id: i32,
|
||||
db: &DB,
|
||||
@@ -633,17 +682,6 @@ pub async fn refresh_token_for_account<'c>(
|
||||
tx.commit().await?;
|
||||
|
||||
let token_str = token.access_token.to_string();
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
let encrypted_token = encrypt(&mc, token_str.as_str());
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
encrypted_token,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
grant_type = %account.grant_type,
|
||||
@@ -817,6 +855,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
|
||||
|
||||
@@ -14,3 +14,6 @@ pub mod resources;
|
||||
pub mod secret_backend_ext;
|
||||
pub mod var_resource_cache;
|
||||
pub mod variables;
|
||||
|
||||
#[cfg(all(test, feature = "oauth2", feature = "private", feature = "enterprise"))]
|
||||
mod oauth_refresh_secret_backend_tests;
|
||||
|
||||
@@ -24,9 +24,8 @@ pub async fn _refresh_token<'c>(
|
||||
id: i32,
|
||||
db: &DB,
|
||||
) -> error::Result<String> {
|
||||
windmill_oauth::refresh_token(
|
||||
let token = windmill_oauth::refresh_token(
|
||||
tx,
|
||||
path,
|
||||
w_id,
|
||||
id,
|
||||
db,
|
||||
@@ -34,5 +33,12 @@ pub async fn _refresh_token<'c>(
|
||||
&windmill_oauth::OAUTH_HTTP_CLIENT,
|
||||
include_str!("../../oauth_connect.json"),
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
|
||||
// Persist the refreshed token through the configured secret backend so an
|
||||
// external backend (Vault / Azure KV / AWS Secrets Manager) is updated too,
|
||||
// not just the in-DB variable mirror.
|
||||
crate::secret_backend_ext::store_oauth_token_value(db, w_id, path, &token).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! E2E regression tests for OAuth token refresh persistence through the
|
||||
//! configured secret backend.
|
||||
//!
|
||||
//! Regression for windmill#9471 / windmill-ee-private#607: the lazy on-fetch
|
||||
//! OAuth refresh used to persist the freshly minted token with a raw
|
||||
//! `UPDATE variable SET value = <db-encrypted>`, bypassing the secret-backend
|
||||
//! abstraction. With an external backend (AWS Secrets Manager / Azure Key
|
||||
//! Vault / Vault) reads resolve through the backend and ignore `variable.value`
|
||||
//! entirely, so the external store stayed frozen at its connect-time token and
|
||||
//! every read that did not itself trigger a mint served a stale/expired token.
|
||||
//!
|
||||
//! These tests exercise the persistence step (`store_oauth_token_value`) — the
|
||||
//! exact code path that was fixed — against both the database backend and an
|
||||
//! external (AWS Secrets Manager via LocalStack) backend, plus the self-healing
|
||||
//! reset on a failed persist. They are opt-in (they mutate the shared
|
||||
//! `global_settings.secret_backend` row and workspace/variable/account rows on a
|
||||
//! real DB) and skip unless `RUN_SECRET_BACKEND_E2E=1` is set.
|
||||
//!
|
||||
//! ## Run
|
||||
//!
|
||||
//! Database-backend case (needs a migrated DB). Note `RUN_SECRET_BACKEND_E2E=1`
|
||||
//! is required or every test skips:
|
||||
//!
|
||||
//! ```bash
|
||||
//! RUN_SECRET_BACKEND_E2E=1 \
|
||||
//! DATABASE_URL=postgres://postgres:changeme@127.0.0.1:5432/windmill \
|
||||
//! cargo test -p windmill-store --features private,enterprise,oauth2 \
|
||||
//! oauth_refresh_secret_backend_tests -- --nocapture --test-threads=1
|
||||
//! ```
|
||||
//!
|
||||
//! External-backend cases additionally need LocalStack `secretsmanager` and
|
||||
//! `RUN_AWS_SM_TESTS=1`:
|
||||
//!
|
||||
//! ```bash
|
||||
//! docker run -d -e SERVICES=secretsmanager localstack/localstack:3.8
|
||||
//! RUN_SECRET_BACKEND_E2E=1 RUN_AWS_SM_TESTS=1 AWS_SM_ENDPOINT=http://<localstack-ip>:4566 \
|
||||
//! DATABASE_URL=postgres://postgres:changeme@127.0.0.1:5432/windmill \
|
||||
//! cargo test -p windmill-store --features private,enterprise,oauth2 \
|
||||
//! oauth_refresh_secret_backend_tests -- --nocapture --test-threads=1
|
||||
//! ```
|
||||
|
||||
use crate::secret_backend_ext::{get_secret_value, store_oauth_token_value, store_secret_value};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
// global_settings holds a single `secret_backend` row shared across tests;
|
||||
// serialize the test bodies so concurrent runs don't clobber each other's
|
||||
// configured backend. (Also run with --test-threads=1 for good measure.)
|
||||
static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Opt-in gate so the suite never runs (and mutates shared DB state) as part of a
|
||||
// normal `cargo test` invocation.
|
||||
fn run_e2e() -> bool {
|
||||
env_flag("RUN_SECRET_BACKEND_E2E")
|
||||
}
|
||||
|
||||
fn run_aws_sm() -> bool {
|
||||
env_flag("RUN_AWS_SM_TESTS")
|
||||
}
|
||||
|
||||
/// Restore the default (database) backend so we don't leave the instance
|
||||
/// pointed at a test backend for any concurrently-running suite.
|
||||
async fn reset_backend(db: &Pool<Postgres>) {
|
||||
set_backend(db, serde_json::json!({ "type": "Database" })).await;
|
||||
}
|
||||
|
||||
fn aws_sm_endpoint() -> String {
|
||||
std::env::var("AWS_SM_ENDPOINT").unwrap_or_else(|_| "http://localhost:4566".to_string())
|
||||
}
|
||||
|
||||
async fn db() -> Pool<Postgres> {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must point at a migrated windmill database");
|
||||
PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL")
|
||||
}
|
||||
|
||||
/// Fresh workspace + key + clean variable/account rows for `w_id`.
|
||||
async fn setup_workspace(db: &Pool<Postgres>, w_id: &str) {
|
||||
sqlx::query("DELETE FROM variable WHERE workspace_id = $1")
|
||||
.bind(w_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM account WHERE workspace_id = $1")
|
||||
.bind(w_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM workspace_key WHERE workspace_id = $1")
|
||||
.bind(w_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM workspace WHERE id = $1")
|
||||
.bind(w_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'admin@windmill.dev')")
|
||||
.bind(w_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_key (workspace_id, kind, key) VALUES ($1, 'cloud', 'e2ekey')",
|
||||
)
|
||||
.bind(w_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn set_backend(db: &Pool<Postgres>, config: serde_json::Value) {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ('secret_backend', $1) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind(config)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn aws_sm_config(endpoint: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "AwsSecretsManager",
|
||||
"region": "us-east-1",
|
||||
"access_key_id": "test",
|
||||
"secret_access_key": "test",
|
||||
"endpoint_url": endpoint,
|
||||
"prefix": "windmill-e2e/"
|
||||
})
|
||||
}
|
||||
|
||||
/// Simulate `Connect`: store the initial token through the backend and create
|
||||
/// the linked secret variable + account (expired, with a refresh token).
|
||||
async fn simulate_connect(db: &Pool<Postgres>, w_id: &str, path: &str, initial_token: &str) -> i32 {
|
||||
let stored = store_secret_value(db, w_id, path, initial_token)
|
||||
.await
|
||||
.expect("store initial token");
|
||||
|
||||
let account_id: i32 = sqlx::query_scalar(
|
||||
"INSERT INTO account (workspace_id, expires_at, refresh_token, client, grant_type) \
|
||||
VALUES ($1, now() - interval '1 hour', 'rt_dummy', 'gdrive', 'authorization_code') \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(w_id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO variable (workspace_id, path, value, is_secret, is_oauth, account, expires_at) \
|
||||
VALUES ($1, $2, $3, true, true, $4, now() - interval '1 hour')",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(path)
|
||||
.bind(&stored)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
account_id
|
||||
}
|
||||
|
||||
async fn variable_value(db: &Pool<Postgres>, w_id: &str, path: &str) -> String {
|
||||
sqlx::query_scalar("SELECT value FROM variable WHERE workspace_id = $1 AND path = $2")
|
||||
.bind(w_id)
|
||||
.bind(path)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn account_expires_in_past(db: &Pool<Postgres>, w_id: &str, account_id: i32) -> bool {
|
||||
sqlx::query_scalar("SELECT expires_at < now() FROM account WHERE workspace_id = $1 AND id = $2")
|
||||
.bind(w_id)
|
||||
.bind(account_id)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Database backend (the "without external storage" case): refresh must
|
||||
/// re-encrypt the new token into `variable.value`; reads serve the new token.
|
||||
#[tokio::test]
|
||||
async fn database_backend_persists_refreshed_token() {
|
||||
if !run_e2e() {
|
||||
println!(
|
||||
"Skipping database_backend_persists_refreshed_token: set RUN_SECRET_BACKEND_E2E=1"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _guard = SERIAL.lock().await;
|
||||
let db = db().await;
|
||||
let w_id = "wm_e2e_db";
|
||||
let path = "f/google/gdrive";
|
||||
|
||||
set_backend(&db, serde_json::json!({ "type": "Database" })).await;
|
||||
setup_workspace(&db, w_id).await;
|
||||
let _ = simulate_connect(&db, w_id, path, "OLD_TOKEN").await;
|
||||
|
||||
// Connect-time token is served.
|
||||
let v = variable_value(&db, w_id, path).await;
|
||||
assert_eq!(
|
||||
get_secret_value(&db, w_id, path, &v).await.unwrap(),
|
||||
"OLD_TOKEN"
|
||||
);
|
||||
|
||||
// Refresh persists the new token.
|
||||
store_oauth_token_value(&db, w_id, path, "NEW_TOKEN")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v = variable_value(&db, w_id, path).await;
|
||||
assert_eq!(
|
||||
get_secret_value(&db, w_id, path, &v).await.unwrap(),
|
||||
"NEW_TOKEN",
|
||||
"database backend should serve the refreshed token"
|
||||
);
|
||||
println!(" ✓ database backend serves refreshed token");
|
||||
reset_backend(&db).await;
|
||||
}
|
||||
|
||||
/// External backend (the "with external storage" case): refresh must write
|
||||
/// the new token to AWS Secrets Manager. Before the fix the external store
|
||||
/// stayed frozen and reads served the stale connect-time token.
|
||||
#[tokio::test]
|
||||
async fn external_backend_persists_refreshed_token() {
|
||||
if !run_e2e() || !run_aws_sm() {
|
||||
println!("Skipping external_backend_persists_refreshed_token: set RUN_SECRET_BACKEND_E2E=1 and RUN_AWS_SM_TESTS=1");
|
||||
return;
|
||||
}
|
||||
let _guard = SERIAL.lock().await;
|
||||
let db = db().await;
|
||||
let w_id = "wm_e2e_awssm";
|
||||
let path = "f/google/gsheets";
|
||||
|
||||
set_backend(&db, aws_sm_config(&aws_sm_endpoint())).await;
|
||||
setup_workspace(&db, w_id).await;
|
||||
let _ = simulate_connect(&db, w_id, path, "OLD_TOKEN").await;
|
||||
|
||||
// Connect-time token is served from the external store.
|
||||
let marker = variable_value(&db, w_id, path).await;
|
||||
assert!(
|
||||
marker.starts_with("$aws_sm:"),
|
||||
"external backend should store a marker in variable.value, got {marker}"
|
||||
);
|
||||
assert_eq!(
|
||||
get_secret_value(&db, w_id, path, &marker).await.unwrap(),
|
||||
"OLD_TOKEN"
|
||||
);
|
||||
|
||||
// Demonstrate the original bug shape: a raw DB write to variable.value is
|
||||
// futile because reads resolve through the backend and ignore it.
|
||||
sqlx::query(
|
||||
"UPDATE variable SET value = 'ignored_db_blob' WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(path)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_secret_value(&db, w_id, path, "ignored_db_blob")
|
||||
.await
|
||||
.unwrap(),
|
||||
"OLD_TOKEN",
|
||||
"reads ignore variable.value for external backends — a raw UPDATE can't refresh the served token"
|
||||
);
|
||||
|
||||
// The fix: persist through the backend.
|
||||
store_oauth_token_value(&db, w_id, path, "NEW_TOKEN")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let marker = variable_value(&db, w_id, path).await;
|
||||
assert_eq!(
|
||||
get_secret_value(&db, w_id, path, &marker).await.unwrap(),
|
||||
"NEW_TOKEN",
|
||||
"external backend should serve the refreshed token written back to AWS SM"
|
||||
);
|
||||
println!(" ✓ external (AWS SM) backend serves refreshed token written back to the store");
|
||||
reset_backend(&db).await;
|
||||
}
|
||||
|
||||
/// If persisting the refreshed token fails (e.g. transient external-backend
|
||||
/// error) after the account was committed fresh, the account expiry must be
|
||||
/// reset to the past so the next fetch retries instead of serving a stale
|
||||
/// token for the whole token lifetime.
|
||||
#[tokio::test]
|
||||
async fn failed_persist_resets_account_expiry() {
|
||||
if !run_e2e() || !run_aws_sm() {
|
||||
println!("Skipping failed_persist_resets_account_expiry: set RUN_SECRET_BACKEND_E2E=1 and RUN_AWS_SM_TESTS=1");
|
||||
return;
|
||||
}
|
||||
let _guard = SERIAL.lock().await;
|
||||
let db = db().await;
|
||||
let w_id = "wm_e2e_selfheal";
|
||||
let path = "f/google/gdrive";
|
||||
|
||||
// Working backend first to seed the variable + a *fresh* account.
|
||||
set_backend(&db, aws_sm_config(&aws_sm_endpoint())).await;
|
||||
setup_workspace(&db, w_id).await;
|
||||
let account_id = simulate_connect(&db, w_id, path, "OLD_TOKEN").await;
|
||||
sqlx::query("UPDATE account SET expires_at = now() + interval '1 hour' WHERE workspace_id = $1 AND id = $2")
|
||||
.bind(w_id)
|
||||
.bind(account_id)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!account_expires_in_past(&db, w_id, account_id).await);
|
||||
|
||||
// Point the backend at an unreachable endpoint so the persist fails.
|
||||
set_backend(&db, aws_sm_config("http://127.0.0.1:1")).await;
|
||||
|
||||
let res = store_oauth_token_value(&db, w_id, path, "NEW_TOKEN").await;
|
||||
assert!(res.is_err(), "persist to unreachable backend should fail");
|
||||
assert!(
|
||||
account_expires_in_past(&db, w_id, account_id).await,
|
||||
"a failed persist must reset account.expires_at to the past so refresh retries"
|
||||
);
|
||||
println!(" ✓ failed persist reset account expiry (self-healing)");
|
||||
reset_backend(&db).await;
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -550,8 +550,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));
|
||||
}
|
||||
}
|
||||
@@ -575,17 +585,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 {
|
||||
@@ -601,14 +618,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!(
|
||||
@@ -652,15 +696,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,
|
||||
@@ -731,13 +795,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?;
|
||||
}
|
||||
@@ -754,13 +819,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);
|
||||
|
||||
@@ -26,7 +26,10 @@ use windmill_common::{
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use windmill_common::{
|
||||
global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING},
|
||||
secret_backend::{AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings},
|
||||
secret_backend::{
|
||||
AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend,
|
||||
AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
@@ -225,7 +228,12 @@ pub async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
|
||||
None => SecretBackendConfig::default(),
|
||||
};
|
||||
|
||||
Ok(matches!(config, SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_) | SecretBackendConfig::AwsSecretsManager(_)))
|
||||
Ok(matches!(
|
||||
config,
|
||||
SecretBackendConfig::HashiCorpVault(_)
|
||||
| SecretBackendConfig::AzureKeyVault(_)
|
||||
| SecretBackendConfig::AwsSecretsManager(_)
|
||||
))
|
||||
}
|
||||
|
||||
/// Get a secret value using the configured backend
|
||||
@@ -252,12 +260,8 @@ pub async fn get_secret_value(
|
||||
// Fetch from Vault directly
|
||||
backend.get_secret(workspace_id, path).await
|
||||
}
|
||||
"azure_key_vault" => {
|
||||
backend.get_secret(workspace_id, path).await
|
||||
}
|
||||
"aws_secrets_manager" => {
|
||||
backend.get_secret(workspace_id, path).await
|
||||
}
|
||||
"azure_key_vault" => backend.get_secret(workspace_id, path).await,
|
||||
"aws_secrets_manager" => backend.get_secret(workspace_id, path).await,
|
||||
_ => Err(Error::internal_err(format!(
|
||||
"Unknown backend: {}",
|
||||
backend.backend_name()
|
||||
@@ -303,6 +307,93 @@ pub async fn store_secret_value(
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a freshly minted OAuth access token to the secret variable backing
|
||||
/// a resource, routing through the configured secret backend.
|
||||
///
|
||||
/// This is the write counterpart of the lazy on-fetch OAuth refresh: it stores
|
||||
/// the token via [`store_secret_value`] (which writes to the external backend —
|
||||
/// AWS Secrets Manager / Azure Key Vault / Vault — when one is configured, or
|
||||
/// encrypts for the database backend) and updates `variable.value` with the
|
||||
/// returned value (the encrypted blob for the DB backend, or a `$...:` marker
|
||||
/// for an external backend). Using a raw `UPDATE variable SET value = <encrypted>`
|
||||
/// here instead would leave the external store frozen at its connect-time token
|
||||
/// while reads (which resolve through the backend) keep serving the stale value.
|
||||
///
|
||||
/// The caller has already committed the `account` row as fresh (advanced
|
||||
/// `expires_at`) by the time we get here. If persisting the token fails — most
|
||||
/// likely a transient error talking to an external backend — that would leave
|
||||
/// the account marked fresh while the served secret is stale, so the on-fetch
|
||||
/// refresh gate (`now() > expires_at`) would skip refresh and keep serving the
|
||||
/// stale token for the whole token lifetime. To avoid that we reset `expires_at`
|
||||
/// to the past (and record `refresh_error`) on failure — looking the account up
|
||||
/// via `variable.account` — so the very next fetch retries the refresh instead.
|
||||
///
|
||||
/// Authorization contract: this performs NO access control. It writes the
|
||||
/// caller-supplied token into the secret variable at `path` and may mutate the
|
||||
/// linked `account` row, so callers MUST have already authorized the operation
|
||||
/// against `workspace_id`/`path` (the OAuth refresh adapters only run after the
|
||||
/// read path has resolved and gated the variable). It is therefore kept
|
||||
/// `pub(crate)` and intended solely for the in-crate refresh adapters.
|
||||
#[cfg(feature = "oauth2")]
|
||||
pub(crate) async fn store_oauth_token_value(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
path: &str,
|
||||
token: &str,
|
||||
) -> Result<()> {
|
||||
let persist = async {
|
||||
let value = store_secret_value(db, workspace_id, path, token).await?;
|
||||
sqlx::query("UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3")
|
||||
.bind(value)
|
||||
.bind(workspace_id)
|
||||
.bind(path)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok::<(), Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = persist {
|
||||
// Mark the account expired again so the next fetch re-runs the refresh
|
||||
// instead of serving the now-stale token until it naturally expires. The
|
||||
// account id is the one linked from the variable being refreshed.
|
||||
let account_id: Option<i32> = sqlx::query_scalar::<_, Option<i32>>(
|
||||
"SELECT account FROM variable WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(path)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten();
|
||||
|
||||
if let Some(account_id) = account_id {
|
||||
if let Err(reset_err) = sqlx::query(
|
||||
"UPDATE account SET expires_at = now() - interval '1 minute', refresh_error = $1 \
|
||||
WHERE workspace_id = $2 AND id = $3",
|
||||
)
|
||||
.bind(format!(
|
||||
"OAuth token was refreshed but persisting it to the secret backend failed: {e}"
|
||||
))
|
||||
.bind(workspace_id)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
workspace_id = %workspace_id,
|
||||
account_id = %account_id,
|
||||
"failed to reset account expiry after token persistence error: {reset_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a secret from the configured backend (if using Vault)
|
||||
///
|
||||
/// For database backend: no-op (DB delete is handled separately)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user