mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
* feat: let operators compose full-code apps when the workspace grants it Adds operator_settings.builder_apps, the second builder right, granted independently of flows because the two are not equally verifiable: a composed flow is checked in full server-side, while a full-code app is a browser-built bundle no check can read. The app right rests instead on forced sandbox isolation, a refusal of Viewer execution mode, and a deploy-time check of every runnable the app can reach. BuilderKind arrives with the second kind and is a mandatory argument, so no gate can mean "either" by accident: one that did would authorize an app write on a flows-only workspace. The only surfaces reading .any() are the ones that are genuinely not per-kind, the seat a membership costs and listing drafts. validate_operator_composed_app checks two surfaces rather than one, because they are not the same list: the policy's triggerables and the runnableByPath entries in the app value, which is what the deployed bundle actually resolves against. Every colon in a triggerable key is a possible split, since execute_component looks up "<component>:<path>" with an unrestricted component string. create_app_raw_source stays closed: it compiles caller-supplied sources with a bundler job on a worker, which is arbitrary code execution. Builders lose nothing, since the browser and the CLI both bundle locally and deploy through the multipart endpoints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4 * fix: list a builder's app drafts and refuse inline code in them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to 6d15ff957df4dcd5129e1ab217eab1a13af62fbb Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check a builder app as it will be stored Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show the home list edit button to builder operators Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: pause the raw app preview for an app an operator deployed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let app builders save a full-code app's deploy settings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: record the builder app settings path, public apps and the editor preview Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: cache the merged EE seat usage query The EE query now reads audit_partitioned and skips service accounts, so the cached entry for its builder_apps predicate no longer matched and offline EE builds failed with no cached data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pause the raw app preview for loaded drafts and unknown authors A draft loaded from an operator ran in the viewer's preview because the pause only checked the app's last deployer, and an author no longer in the workspace read as a developer. The pause now checks a loaded draft's owner, records it in the viewer's draft until the preview is run or deployed, and holds when the author cannot be found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: name guest apps among the modes a builder may deploy Guest runs as the publisher with no Viewer fallback, like Publisher and Anonymous, and RestrictGuestAppDeployment refuses it per workspace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a loaded draft's preview paused in AI sessions and chat edits The session draft codec and the chat's draft save dropped loaded_from, so opening a paused operator draft in an AI session, or editing it through the chat, ran it with the viewer's session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin builder refusals for low-code apps and unreviewed previews Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the operator mark when loading a developer's copy of a draft Loading another user's draft recorded only who saved it, so a developer's copy of an operator draft they never previewed opened unpaused for the next person who loaded it, running the operator's code with their session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let builders restore a full-code app from deployment history Restore always sends allow_kind_change, which the builder check refused even for a full-code version restored over a full-code app. Builders now drop the flag, so the locked kind check still refuses an actual conversion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style: rustfmt the builder checks in jobs.rs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: record the builder kind-check race as an accepted risk Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: never trust the owner name a loaded draft carries A loaded draft's loaded_from is written by its owner, so an operator could name a developer, or leave it empty, and the next person to load the draft previewed it unpaused. A loaded draft that carries any mark now stays paused as unattributable; an unmarked one is still checked against its real owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: record viewer-supplied resources in builder apps as an accepted risk Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say that the app right lets operators write frontend code The settings section and the docs told admins builder operators "still cannot write code", but the app right has them write the app's frontend bundle. Only backend code stays out of reach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: state the raw-source closure once and drop an unreachable preview case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf: look up only the author when deciding whether to pause the preview Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin that the app right alone bills operators as developers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: point ee-repo-ref at the EE seat test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop the editor preview pause now that sandboxed apps preview isolated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse a builder operator's app draft without the sandbox Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: turn the sandbox on when a builder operator opens the app editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse builder app drafts naming runnables they cannot read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: note the editor preview asks before minting a builder app token Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: trim the builder app check comments to the constraints Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse an app runnable whose runType is not script, flow or hub Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check builder app draft policy keys like a deploy does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: count main's half-seat service accounts in the builder billing test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: name the builder policy check for drafts as well as deploys Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let a builder app draft autosave with a stale triggerables_v2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check the script version a builder app would run, not any version Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse viewer mode in a builder app draft as on deploy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: hold builder app drafts and deploys to one check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say a builder's script may not have finished deploying Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: cap builder app triggerable keys before reading every colon split Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse a builder app draft that runs as someone else Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: conform builder app drafts saved by the AI chat as the editor does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: record that a deployed builder draft runs as its deployer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse builder app runnables not written as plain text Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: a builder draft may carry its builder's own run identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer builders Start with AI only when it opens a session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name full-code apps in the builder rights confirmation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: name only the granted builder rights in the confirmation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: note a wm_deployers builder may keep another run identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: give builders a starter app that calls no missing runnables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: seed the builder starter under the picker for builders Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: qualify the builder run identity comment for wm_deployers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: resolve builder app script hashes before opening the RLS transaction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the builder app tests serially to share the rights cache safely Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: give builders the builder starter on a fresh editor load Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop a new-app load overtaken while waiting for the workspace list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep main's 401 for operators previewing without the app right Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to 5d7726c4979070ee8db4fb9e8014152872a641ba This commit updates the EE repository reference after PR #816 was merged in windmill-ee-private. Previous ee-repo-ref: 7151060bfc02908e97d9344b3820ee33a4951322 New ee-repo-ref: 5d7726c4979070ee8db4fb9e8014152872a641ba Automated by sync-ee-ref workflow. * refactor: let the app row's edit button trust its write flag Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: point ee-repo-ref at the merged EE companion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: note the session raw-app editor does not conform builder drafts yet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
195 lines
14 KiB
Markdown
195 lines
14 KiB
Markdown
# Operator builder rights
|
|
|
|
Two workspace settings, `operator_settings.builder_flows` and `operator_settings.builder_apps`,
|
|
that let every operator of that workspace compose flows and full-code apps out of runnables that
|
|
already exist. Neither lets them author backend code or run arbitrary code on a worker, the
|
|
boundary the operator role draws. The app right does let them write the app's frontend code,
|
|
which runs in the viewer's browser and is why that right rests on sandbox isolation.
|
|
|
|
They are granted **independently**, because the two are not equally verifiable. A composed flow is
|
|
checked in full server-side (`check_flow_is_composition_only` refuses everything carrying code).
|
|
A full-code app is a browser-built bundle no server-side check can read, and rests instead on
|
|
forced `sandbox` isolation plus the viewer consent prompt. An admin may reasonably want the first
|
|
without the second, so **never gate on "either"**: a gate that does authorizes the kind its
|
|
workspace never granted, which is the whole point of the split. `BuilderKind` exists to make each
|
|
call site say which one it means.
|
|
|
|
These are write rights, unlike the visibility flags beside them, and unlike the withdrawable
|
|
rights in `docs/operator-write-rights.md` they are granted on request and cost a seat. Read them
|
|
with `windmill_common::workspaces::operator_builder_rights` (60s cache, shared with the
|
|
withdrawable rights), which returns an `OperatorBuilderRights { flows, apps }`, and gate a write
|
|
with `check_operator_can_build`, naming the `BuilderKind`. `.any()` is only for surfaces that are
|
|
genuinely not per-kind: the seat a membership costs, and listing drafts.
|
|
|
|
## What the check has to cover
|
|
|
|
`check_flow_is_composition_only` (`windmill-common/src/flows.rs`) walks a `FlowValue` and refuses
|
|
anything that carries code. Three of its rules exist because the obvious walk misses them:
|
|
|
|
- **`FlowScript` and any populated `modules_node` / `default_node`.** These name code hoisted into
|
|
a `flow_node` row. Only the dependency job produces them, so an authored value carrying one
|
|
names code stored under some other flow. The walk covers `modules`, so a node reference is a way
|
|
past it.
|
|
- **An AI agent step's `tools`.** `ToolValue::FlowModule` wraps a whole `FlowModuleValue`, so a
|
|
tool can be a raw script.
|
|
- **An AI agent step's `agent` link.** A linked agent resolves its tools from an `ai_agent`
|
|
resource at run time, and operators may write resources, so the tool list is outside this check
|
|
and can be swapped for a raw script after the flow is approved.
|
|
|
|
It also returns what a value-only walk cannot authorize, for the caller to check against its own
|
|
permissions:
|
|
|
|
- **the worker tags the steps pin**, or a builder routes a job onto a privileged worker group;
|
|
- **every runnable a step references**. `script_to_payload` resolves a step's path with the root DB
|
|
handle (`db_authed = None`) and returns the referenced runnable's `on_behalf_of`, which
|
|
`worker_flow` then applies to the step job. So composing a path is enough to run it, and to run
|
|
it as whoever it runs as: `validate_operator_composed_flow` re-checks each path under the
|
|
caller's RLS. This is the general case; the one below is on top of it, not instead of it.
|
|
- **the `(path, hash)` of every version-pinned step**. A step carrying a `hash` is dispatched by
|
|
that hash alone, with the path beside it never consulted, so a readable path paired with another
|
|
script's hash still runs that other script.
|
|
|
|
The value is not the only code a flow carries: the schema's `x-windmill-dyn-select-code` fills its
|
|
dynamic dropdowns, run as whoever loads the form, so `validate_operator_flow` refuses it like a
|
|
step's code. A builder therefore cannot save a developer's flow that has dropdowns; its editor
|
|
still previews them through the deployed flow rather than the inline route operators are refused.
|
|
Which developer flows a builder may edit at all is left to permissions: write access to the flow
|
|
or its folder.
|
|
|
|
Call it on every write **and** every preview: `run_preview_flow_job` and
|
|
`push_flow_dependencies_job` both take a request-supplied flow value, so leaving either out makes
|
|
it the way to run what the write path refuses. A flow draft goes through `validate_operator_flow`
|
|
too: a developer who loads a builder's draft in the editor runs its code as themselves.
|
|
|
|
The same reasoning applies to a builder-authored app, with one extra step. `execute_component`
|
|
resolves the runnable it runs on the root handle, so `validate_operator_composed_app` checks every
|
|
referenced path under the caller's RLS and refuses hub ones. For a script it checks the version a
|
|
run would pick: grants are per version, and a path reused after its versions were archived starts
|
|
with none while the archived ones keep theirs. But it has to check **two** surfaces,
|
|
because they are not the same list: the policy's `script/<path>` and `flow/<path>` triggerables,
|
|
and the by-path entries of the app value's `runnables` map, which is what the editor resolves a
|
|
`runnable_id` against and sends. Read only that map, and refuse an entry whose `type`, `path` or
|
|
`runType` is not text.
|
|
|
|
Reading a triggerable key is not a `split_once(':')`: `execute_component` looks up
|
|
`format!("{component}:{path}")` with an unrestricted component string, so `a:b:script/x` resolves
|
|
at run time for `component = "a:b"`. Every colon is a possible split, so the check validates every
|
|
suffix that parses as a runnable rather than guessing which one a request will use.
|
|
|
|
What makes those checks bind is that **`ExecutionMode::Viewer` is refused for a builder app**. In
|
|
Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/`
|
|
path, so the policy stops being the list of what the app may invoke, and the job runs as the
|
|
*viewer*: an admin who merely opened the app would run anything in the workspace as themselves.
|
|
`Publisher`, `Guest` and `Anonymous` have no such fallback; all three run as the publisher. If you ever relax the Viewer refusal, the
|
|
deploy-time path checks above stop being an authorization boundary.
|
|
|
|
## The two raw-app deploy paths are not equivalent
|
|
|
|
`create_app_raw` / `update_app_raw` are multipart: the browser already built the bundle, and
|
|
nothing server-side compiles anything. Builders use these.
|
|
|
|
`create_app_raw_source` / `update_app_raw_source` push a bundler CLI over caller-supplied `files`
|
|
as a job **on a worker**, which is arbitrary code execution and is why they already require
|
|
`jobs:run` on top of `apps:write`. They stay closed to operators, builder rights or not. Do not
|
|
"tidy" the exception away: it costs builders nothing, because the browser and the CLI both bundle
|
|
locally and deploy through the multipart endpoints.
|
|
|
|
A builder-authored app is forced to `policy.sandbox = true`. That is what makes it safe to let an
|
|
operator publish a bundle nobody reviewed: without it the bundle runs same-origin with each
|
|
viewer's Windmill session. The same check refuses a `rawscript/<sha>` key in either triggerables
|
|
map, since that key is the deployed app's authorization to run caller-supplied `raw_code` hashing
|
|
to it. It reads the value as stored, NUL escapes stripped: a stripped NUL can turn a key it
|
|
ignored into `inlineScript`.
|
|
|
|
`update_app` takes a builder too, but settings only: the deploy panel saves a deployed full-code
|
|
app's access and frontend scopes through it with no value. It reaches the same check, so a builder
|
|
can no more switch the sandbox off there than on deploy.
|
|
|
|
A builder's **draft** goes through the same `check_operator_composed_app` as its deploy, with
|
|
`BuilderAppWrite::Draft`: an admin may open it in the editor or deploy it as it stands, so any
|
|
rule added for deploys must hold for drafts too. Where the two differ, `write` decides: a deploy
|
|
fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips
|
|
`triggerables_v2` and must not name another user's run identity. Every frontend writer of a
|
|
builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded
|
|
from someone else's app breaks these rules: the editor and the AI chat do. The AI Sessions raw-app
|
|
editor, open to operators only behind a per-browser opt-in, does not, so its autosaves are refused.
|
|
|
|
The raw-app editor previews an app isolated only when its policy has `sandbox`, and otherwise runs
|
|
the bundle same-origin with the session of whoever opens it. A builder's draft is therefore
|
|
held to the same rule as its deploy: `update_draft` refuses one whose `policy.sandbox` is not
|
|
`true`, so whatever an admin opens of an operator's, deployed or drafted, previews isolated.
|
|
Turning the sandbox off in their own editor stays that admin's explicit choice. It also refuses
|
|
a draft naming a runnable the builder cannot read, or a hub one, like the deploy: the editor
|
|
preview runs them as whoever opens it, and admits any path for an admin. That covers the
|
|
policy's legacy `triggerables` too, `rawscript/` keys included: every deploy of a draft
|
|
carries it through untouched, so an admin deploying the draft would publish it. Its
|
|
`triggerables_v2` is not checked: every deploy of a draft rebuilds it from the runnables, the
|
|
preview never reads it, and a copy gone stale since the last deploy would otherwise block
|
|
autosave. `Viewer` mode is refused in a draft as on deploy: the deploy panel shows it as
|
|
members-only, so an admin deploying the draft would publish it unawares. The builder's editor
|
|
switches it to `Publisher` on load, since that panel offers no way out of it. A draft naming a
|
|
run identity other than its builder is refused for the same reason: a builder's own deploy resets
|
|
it, but an admin's one-click draft deploy preserves it without showing it. The builder's editor
|
|
drops another user's identity on load, which an app that user deployed carries. A builder in
|
|
`wm_deployers` is the exception: like any member, their deploy may keep another identity, which
|
|
was judged acceptable for operators in that group (WIN-2536) and is the same for composed flows.
|
|
|
|
Reading a triggerable key at every colon copies it once per colon, so the check refuses a key
|
|
over 512 bytes before splitting: a quadratic walk otherwise lets a sub-megabyte key allocate
|
|
gigabytes. A 255-character path plus a component name stays well under it.
|
|
|
|
## Billing
|
|
|
|
An operator of a builder workspace consumes a full author seat. This is the one place that reads
|
|
"either right", and deliberately so: either one makes an operator an author of deployable
|
|
artifacts, and there is no half-author, so granting both costs no more than granting one. Do not
|
|
introduce per-right pricing — it turns one boolean into a capability matrix that billing, the cap
|
|
check and the displayed count would each have to agree on.
|
|
|
|
`consumes_operator_seat` is the seat-role helper; the EE counting queries share
|
|
`OPERATOR_SEAT_SQL` so the displayed, enforced and reported numbers agree. The one exception is
|
|
`get_user_usage` in `stats_ee.rs`: it is a compile-checked `query!`, which cannot interpolate the
|
|
constant, so it spells the predicate out. Change both together. On cloud, `billable_seats` applies
|
|
the same rule; see its doc for the out-of-repo invoice it must match.
|
|
|
|
Granting the first right runs `check_seat_cap_for_operator_builder`, which prices the change by
|
|
counting seats twice rather than by counting the workspace's operators: an operator who already
|
|
authors elsewhere must not be charged again. Adding the second right later, or re-saving settings
|
|
that already have one on, is a zero delta and never blocks.
|
|
|
|
## Accepted risks
|
|
|
|
- A builder raw app may declare `frontend_sdk_scopes`, and `mint_raw_app_sdk_token` mints as the
|
|
*viewer*. A consenting admin therefore hands the bundle a 12h admin-identity token within the
|
|
curated scope list. The viewer consent prompt is the gate, shown by the editor preview as well
|
|
as the deployed app, before any token is minted. The lever, if this is ever revisited,
|
|
is dropping `variables:read` / `resources:read` / `jobs:run` from `FRONTEND_SDK_ALLOWED_SCOPES`
|
|
for builder apps.
|
|
- The raw-app editor preview runs an app's runnables as whoever has the editor open, where the
|
|
deployed app runs them as its publisher. An admin opening a builder's app in the editor lets its
|
|
frontend code run, with the admin's rights, any runnable the builder can read. Running as the
|
|
draft's author instead would need a preview that ignores unsaved edits to the runnable list.
|
|
- A builder may make an app public (`ExecutionMode::Anonymous`): anyone with the URL runs the
|
|
runnables its policy names, as the builder. Refusing it is the workspace's call, through the
|
|
`RestrictAnonymousAppDeployment` protection rule. A `Guest` app is the same with a login in
|
|
front: anyone the instance's identity provider admits, member or not, once guest access is
|
|
enabled. `RestrictGuestAppDeployment` refuses it.
|
|
- A builder raw app may set `allow_user_resources` on a runnable's inputs, and the bundle picks the
|
|
`$res:` path it sends. `build_args` resolves that path as the *viewer*, with no prompt, into a
|
|
job that runs as the builder. Getting a value out takes a guessable path and a readable script
|
|
that returns, logs or sends its input. Developers can do the same; the lever is refusing a
|
|
non-empty `allow_user_resources` (and `s3_inputs[].allow_user_resources`) for builders.
|
|
- A builder's delete or settings-only update reads the app's kind before the write, not under a
|
|
lock held through it. An admin converting that app to low-code in between lets the builder's
|
|
request land on the low-code app; the builder already has write access to its path.
|
|
- A builder's draft carries no run identity other than the builder's own, and deploying one that
|
|
carries none makes the deployer its run identity, as any deploy does. An admin deploying such a
|
|
draft, from the editor or Compare & Deploy, therefore publishes the builder's code running as
|
|
that admin; the deploy is the admin's explicit act, as it is for a developer's draft.
|
|
- All-or-nothing per workspace: there is no per-user builder role.
|
|
- `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and
|
|
the billed seat count with it.
|
|
- A builder writes JavaScript expressions: step inputs, branch predicates, `stop_after_if` and
|
|
`suspend`. They run in QuickJS with no filesystem or network access, and forbidding them would
|
|
leave nothing to compose with.
|