Round 2 caught that the app-side check was bound to the wrong surface. It checked the policy triggerables, on the premise that they are what `execute_component` resolves. That holds for `rawscript/<sha>`, which is why the raw-script refusal binds, but not for `script/`/`flow/`: in `ExecutionMode::Viewer` run mode falls back to a default triggerable for any such path, so a path absent from the map is invocable rather than forbidden, and the job runs as the viewer. A builder could therefore ship an app pointing at a runnable nobody granted them, and an admin who merely opened it would run it as themselves. - `ExecutionMode::Viewer` is refused for a builder-authored app. `Publisher` and `Anonymous` have no fallback, so the triggerables are exhaustive for them and the deploy-time checks below are an authorization boundary rather than advice. - The value's `runnableByPath` entries are authorized too. They are a separate surface from the policy: the deployed bundle resolves a `runnable_id` against the stored `runnables` and sends that path, so an app with an empty triggerables map still reaches one. Also from round 2: `check_flow_is_composition_only`'s doc now states what it actually returns, and the flow-side refs are deduped, so a flow stepping through one script thirty times stops issuing thirty round trips on every write, preview and dependency job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.5 KiB
Operator builder rights
A workspace setting (operator_settings.builder) that lets every operator of that workspace
compose flows and full-code apps out of runnables that already exist. It does not make them
authors: the boundary the operator role draws is authoring code and running arbitrary code,
and builder rights do not move it.
Read the flag with windmill_common::workspaces::operator_builder_enabled (60s cache). Gate a
write with check_operator_can_build. The cache is per process, so revoking the setting has to
reach every replica: an AFTER UPDATE OF operator_settings trigger writes a
notify_operator_settings_change row and process_notify_event drops the entry. Keep both ends
if you touch either, or a revoked workspace keeps authorizing writes on every other replica until
its own entry expires.
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:
FlowScriptand any populatedmodules_node/default_node. These name code hoisted into aflow_noderow. Only the dependency job produces them, so an authored value carrying one names code stored under some other flow. The walk coversmodules, so a node reference is a way past it.- An AI agent step's
tools.ToolValue::FlowModulewraps a wholeFlowModuleValue, so a tool can be a raw script. - An AI agent step's
agentlink. A linked agent resolves its tools from anai_agentresource 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_payloadresolves a step's path with the root DB handle (db_authed = None) and returns the referenced runnable'son_behalf_of, whichworker_flowthen 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_flowre-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 ahashis 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 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. 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 runnableByPath entries in the app value, which is what the deployed bundle resolves a
runnable_id against and sends.
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 and Anonymous have no such fallback. If you ever relax the Viewer refusal, the
deploy-time path checks above stop being an authorization boundary.
execute_component's preview branch refuses a hub path for operators too:
require_path_read_access_for_preview admits hub/ for everyone.
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.
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 (check_operator_composed_app). 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. That key is the
deployed app's authorization to run caller-supplied raw_code hashing to it (execute_component's
run mode authorizes inline code by the policy alone; its operator guard only covers preview mode),
so pinning one would be arbitrary code execution behind a value that passed the inline-script
check. It also uses app_value_has_inline_script rather than traverse_app_inline_scripts: the
latter only reports a script whose language parses, and the author picks the fields.
Accepted risks
- A builder raw app may declare
frontend_sdk_scopes, andmint_raw_app_sdk_tokenmints 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. The lever, if this is ever revisited, is droppingvariables:read/resources:readfromFRONTEND_SDK_ALLOWED_SCOPESfor builder apps. - All-or-nothing per workspace: there is no per-user builder role.
operator_settingsis git-synced, so a pull can flip every operator's class in a workspace and the billed seat count with it.
Billing
An operator of a builder workspace consumes a full author seat. consumes_operator_seat is the
seat-role helper; the EE counting queries share OPERATOR_SEAT_SQL so the displayed, enforced and
reported numbers agree. Enabling the setting 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.