feat: let operators compose flows when the workspace grants the right

Adds operator_settings.builder_flows: a workspace setting that lets every
operator compose flows 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 this does not move it: check_flow_is_composition_only walks
the value and refuses anything carrying code, including the shapes an obvious
walk misses (code hoisted into a flow_node, an AI agent step's tools, and a
linked ai_agent resource whose tool list is resolved at run time).

What the walk cannot settle it returns for the caller to authorize under RLS:
the worker tags the steps pin, every runnable they reference, and the (path,
hash) of every version-pinned step. Composing a path is enough to run it and to
run it as whoever it runs as, since the worker resolves a step's path with the
root DB handle and adopts that runnable's on_behalf_of. A pinned hash needs its
own check because dispatch ignores the path beside it.

The gate runs on every write and on both request-supplied-value paths, flow
preview and flow dependencies, or either becomes the way to run what the write
path refuses.

Operators of a builder workspace consume a full author seat; the EE companion
carries the counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4
This commit is contained in:
AlexRV12
2026-09-18 17:53:11 +02:00
co-authored by Claude Opus 5
parent 625116e378
commit 0df128cc93
29 changed files with 1120 additions and 101 deletions
+60
View File
@@ -0,0 +1,60 @@
# Operator builder rights
`operator_settings.builder_flows` lets every operator of a workspace compose flows 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 this does not move it.
It is a write right, unlike the visibility flags beside it, and unlike the withdrawable rights in
`docs/operator-write-rights.md` it is granted on request and costs a seat. Read it with
`windmill_common::workspaces::operator_can_build_flows` (60s cache, shared with the withdrawable
rights) and gate a write with `check_operator_can_build_flows`.
## 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.
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.
## Billing
An operator of a builder workspace consumes a full author seat: composing deployable artifacts
makes them an author, and there is no half-author. `consumes_operator_seat` is the seat-role
helper; the EE counting queries share `OPERATOR_SEAT_SQL` so the displayed, enforced and reported
numbers agree.
Granting the 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, so re-saving settings that already have the right on is a
zero delta and never blocks.
## Accepted risks
- 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.