Found by the local Codex and Claude review passes. Each one let an operator with builder rights reach code the composition check was supposed to keep out. - Version-pinned flow steps. A step carrying a `hash` is dispatched by that hash alone: `script_to_payload` ignores the path beside it, reads the row with root permissions, and takes that script's tag and `on_behalf_of` identity. A builder could pin the hash of a script it cannot read under a path it can, and run that instead, possibly as the identity that script runs as. The walk now reports every `(path, hash)` pair and `validate_operator_composed_flow` verifies each against the caller's own permissions. - Raw-script triggerables in a builder-authored app policy. `execute_component`'s run mode authorizes caller-supplied `raw_code` by the policy's `rawscript/<sha>` key alone; its operator guard only covers preview mode. A builder could deploy a clean value whose policy pinned an arbitrary sha, then execute matching code on a worker. Both triggerables maps are now refused such a key, which also closes the policy-supplied worker tag that rode with it. - `inlineScript` without a `language`. `traverse_app_inline_scripts` only reports a script whose language parses and stops descending at the key, which is right for locking and wrong for an authorization check the author's fields control. Replaced with `app_value_has_inline_script`, which refuses the key itself. Also: the builder flag gates writes and was cached per process, so revoking it left every other replica authorizing until its own entry expired. An `AFTER UPDATE OF operator_settings` trigger now emits `notify_operator_settings_change` and `process_notify_event` drops the entry, the same way the other authorization-adjacent caches propagate. And `CreateActionsMenu`'s option list read the store outside a reactive context, so switching workspace without a reload kept the previous workspace's kinds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.0 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;
- the
(path, hash)of every version-pinned step. A step carrying ahashis dispatched by that hash alone:script_to_payloadignores the path, reads the row with root permissions, and takes that script's tag andon_behalf_ofidentity. An unverified pair therefore runs some other script's code, possibly as some other identity, from behind a path the builder may read.
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.