14 KiB
Operator capabilities (API surface)
What a user with the operator role can already do today through their token, and where the boundary is actually enforced. This is the baseline for any feature whose scope rule is "anything the operator's token can do today, nothing more".
Two things are worth knowing before reading the rest:
- Postgres RLS does not know what an operator is.
set_session_contextreceivesis_admin, username, groups, and the read/write folder lists — notis_operator(windmill-common/src/db.rs:242). So at the data layer an operator is an ordinary member, and every table's policies grant them exactly what their ACLs grant. - The operator boundary is a hand-maintained deny list in 30 request handlers.
Anything not on that list is reachable. The workspace's
operator_settingsis not part of it: it only filters the sidebar and the AI chat's page list in the browser.
1. Where the role comes from
| Storage | usr.operator (per workspace, per user) |
| Resolved into | Authed.is_operator (windmill-common/src/auth.rs:245) |
| Instance groups | best matching role wins, "operator" → is_operator (windmill-common/src/users.rs:277-311) |
| Superadmins | is_admin = true, is_operator = false on the workspace-scoped paths (windmill-common/src/auth.rs:391, windmill-api-auth/src/auth.rs:463) — not on the global ones below. The superadmin_secret token is its own arm and stays false everywhere (windmill-api-auth/src/auth.rs:515-525) |
| Non-workspace-scoped routes | is_operator = true, with empty groups and folders, for every caller resolved through the usr lookup — workspace admins and superadmins included — because there is no usr row to read when the path carries no workspace (windmill-api-auth/src/auth.rs:403-497). The two token kinds that never reach that branch are the superadmin_secret token (above) and jwt_ tokens, which carry claims.is_operator verbatim (auth.rs:208). So is_operator implies usr.operator = true only on /api/w/{workspace}/… routes; on global routes it is close to meaningless |
| Job tokens | the flag is persisted in job_perms and rebuilt from it, so a $WM_TOKEN minted for an operator's job is itself operator-flagged (windmill-queue/src/jobs.rs:6769, auth.rs:514) |
| Token scopes | orthogonal — scopes only ever narrow. An operator may mint themselves API tokens (POST /users/tokens/create, verified 201) but never a token more privileged than they are (ensure_scopes_within_caller) |
2. The enforcement layers
frontend operator_settings ......... sidebar + AI-chat page list only. NOT enforced server-side.
handlers `if authed.is_operator` ... 30 hard denials (§3) + 16 list-shaping filters (§4).
RLS (operator-blind) .......... ordinary member ACLs on every table.
3. Hard denials — the complete list
Every place an operator is rejected outright. Grep-stable via authed.is_operator — but run
that grep against a checkout with windmill-ee-private present, or the *_ee.rs symlinks
dangle and the EE rows below are invisible.
| Area | Handler | Location |
|---|---|---|
| Scripts | create (all deploy paths) | windmill-api-scripts/src/scripts.rs:1004 |
| archive by path / by hash | scripts.rs:3317, scripts.rs:3408 |
|
| Flows | create / update / archive / delete | windmill-api-flows/src/flows.rs:524, 1079, 1734, 1879 |
| Apps | create / update / delete | windmill-api/src/apps.rs:2044, 2591, 2397 |
| raw app create / update (bundle and source) | apps.rs:1976, 2919, 2780, 2644 |
|
| raw-app preview SDK token | apps.rs:1429 |
|
execute_component in preview mode only |
apps.rs:3493 |
|
| app S3 upload/download with a caller-supplied policy | apps.rs:3989, 4629 |
|
| Drafts | save any draft (read is deliberately still allowed) | windmill-api/src/drafts.rs:689 |
| Jobs | run/preview, run_inline/preview, run/preview_bundle |
windmill-api/src/jobs.rs:7912, 8013, 8263 |
run/preview_flow |
jobs.rs:8947 |
|
run/dependencies, run/flow_dependencies |
jobs.rs:8444, 8564 |
|
run/dynamic_select when the runnable is inline |
jobs.rs:9075 |
|
| resolve jobs | jobs.rs:11144 |
|
| Triggers | use of an admin-configured native integration (calendar/drive/repo pickers) | windmill-native-triggers/src/lib.rs:1639 |
| OAuth (EE) | connect an account with the shared instance credentials, and the client-credentials token exchange — is_operator || read_only → "Connecting with the shared instance credentials requires a read-write workspace member" |
windmill-api/src/oauth2_ee.rs:591, 697 (lines as of backend/ee-repo-ref.txt 88568d1) |
Two threads run through this: arbitrary request-supplied code and deploying runnables
account for all but the last row. The OAuth pair is a third, narrower one — the account
table has no RLS, so spending the admin-configured shared credentials is restricted to
read-write members by an explicit check rather than by ACLs.
4. List-shaping (not denials)
These change what a listing returns; the underlying object stays reachable by path.
- Draft-only rows are never appended for an operator, and
/drafts/listreturns[](drafts.rs:106,scripts.rs:357,flows.rs:235,apps.rs:536,runnables.rs:510/522/941,windmill-api-schedule/src/lib.rs:837,windmill-store/src/resources.rs:406,windmill-store/src/variables.rs:232,windmill-trigger/src/handler.rs:623). - Library scripts (
auto_kind = 'lib') are always filtered out (scripts.rs:254,runnables.rs:476/760). /scripts/listis forced tokind = 'script'(scripts.rs:309), hiding failure/trigger/ approval/preprocessor scripts from the list only./debugomitsregistry_config(windmill-api-debug/src/lib.rs:495).
Verified: a
kind: failurescript is absent from an operator's/scripts/list, yetGET /scripts/get/p/f/shared/failreturns it with full source (200) andPOST /jobs/run/p/f/shared/failruns it (201).
5. What an operator's token can do
Runtime-verified against a CE dev backend (v1.787.0) with an operator who is a member of
group all, which owns folder f/shared — i.e. an operator with ordinary write ACLs. Full
transcript in §8.
Runnables and jobs
| Capability | Result |
|---|---|
| List/read scripts, flows, apps, raw apps they have ACL for | ✅ — including full script source |
Run a deployed script by path (/jobs/run/p/…, /jobs/run_wait_result/p/…) |
✅ |
Run a deployed flow by path (/jobs/run/f/…) |
✅ |
Run a script of any kind (failure/trigger/approval/preprocessor) by path |
✅ |
| Execute app components through a deployed app policy | ✅ (viewer path; preview path denied) |
| Run request-supplied code (preview / inline / bundle / preview flow / dependencies) | ❌ denied |
Job history
Visibility is pure RLS on v2_job — the same rule as for any member:
own jobs (permissioned_as = u/<me>)
OR jobs permissioned as one of my groups
OR visible_to_owner AND runnable_path is under a folder/group/user namespace I can read
An operator therefore sees other people's runs, including an admin's, whenever the runnable lives in a folder they can read. Verified for that case:
/jobs/list, /jobs/completed/list include the admin's runs |
✅ |
/jobs_u/get/{id}, /get_logs/{id}, /get_args/{id}, /completed/get_result/{id} on an admin's job |
✅ 200 |
Same, for a job whose runnable is in a folder they cannot read (f/private) |
❌ 403 |
Cancel another user's running job (/jobs_u/queue/cancel/{id}) |
✅ 200 — an admin's 60 s job was cancelled by the operator |
/jobs/delete, /jobs/list_filtered_uuids |
❌ 403 admin-only |
Saved-input history for a runnable (/inputs/history) |
✅ — other users' past run args |
Everything else is a plain member
These are not gated on is_operator anywhere; only ACLs apply.
| Capability | Result |
|---|---|
Read a secret variable's value (/variables/get_value/{path}) |
✅ "s3cr3t" |
| Create / update / delete variables, including secrets | ✅ |
Read resource values, incl. get_value_interpolated (resolves secrets) |
✅ |
| Create / update / delete resources | ✅ |
| Create a workspace-global resource type | ✅ 201 |
| Create / update / delete / enable-disable schedules on any runnable they can read | ✅ |
| Create / update / delete triggers (HTTP routes, websocket, kafka, …) | ⚠️ code-verified only — windmill-trigger/src/handler.rs:478 gates on scopes + RLS with no operator check. Not runtime-verified: no trigger cargo feature is compiled in the dev backend. Schedules, the always-compiled member of the same family, were runtime-verified. |
| Create folders; add owners to folders they own | ✅ |
| Create groups | ✅ (adding users to a group they don't own is refused) |
Change granular ACLs on objects they own (/acls/add) |
✅ |
List all workspace users with their emails; whois |
✅ |
List workers and worker-group configs (incl. init_bash) |
✅ |
Use the workspace AI proxy (/ai/proxy/…) |
✅ (reaches provider dispatch) |
| Mint API tokens for themselves with arbitrary scopes | ✅ |
| Star favorites, list assets, list AI skills, flow conversations | ✅ |
| Workspace settings, dependency map, pending invites, operator settings, concurrency groups, user administration | ❌ 403 admin-only |
| Instance settings, service logs, impersonation | ❌ superadmin/devops only |
6. operator_settings is not an authorization boundary
workspace_settings.operator_settings has ten flags (runs, schedules, resources, variables,
assets, triggers, audit_logs, groups, folders, workers). Nothing in the backend reads them
for authorization — the only backend touch points store and return the JSON
(windmill-api-workspaces/src/workspaces.rs:4991, 8973). They are consumed by
OperatorMenu.svelte and by the AI chat's allowedOpenPages()
(frontend/src/lib/components/copilot/chat/global/core.ts:2289).
Verified: with all ten flags set to false, the same operator token still gets 200
and full payloads from /jobs/list, /schedules/list, /variables/list, /resources/list,
/folders/list, /groups/list, /audit/list, /assets/list and /workers/list.
Anything that treats operator_settings as a permission — including chat tool filtering —
is enforcing a UI preference, not a boundary. That is fine for shaping what a tool
advertises; it must not be the only thing standing between the operator and the data.
7. Consequences for a "nothing more than the token" scope rule
- Run script / run flow by path is already fully allowed, synchronously or async, for anything the operator can read. No new capability is needed and none should be added.
- Preview / inline / raw code is the one hard line. Every arbitrary-code entry point is already blocked; a tool that accepts code and runs it would breach the baseline even if it merely wrapped an existing endpoint.
- Job history is folder-scoped, not user-scoped. A tool may surface other users' runs, logs, args and results — but only after the same RLS check, i.e. by going through the existing endpoints rather than a root-DB query.
- Reading secrets is within the baseline.
variables/get_valueand interpolated resources already return plaintext secrets to an operator with ACL. Redaction is a product decision, not a permission one. - Deploy-shaped mutations are not. Scripts, flows, apps and drafts are denied; but variables, resources, resource types, schedules, triggers, folders, groups and ACLs are not, so "operators are read-only" is false and should not be assumed anywhere.
operator_settingscannot be used to justify exposing less or more. Filter tools with it for UX consistency, but derive the security boundary from §3–§5.- Never gate on
is_operatoroutside a workspace-scoped route. On/api/…routes with no workspace in the path the flag is unconditionallytrue(§1), so such a check would reject workspace admins and superadmins too.
8. How this was verified
Dev backend CE v1.787.0, workspace aud2, three principals: admin (workspace admin),
operator (usr.operator = true), member (plain). Folder f/shared is owned by group
all with write, f/private by u/admin only; both hold a script, and f/shared also
holds a flow, an app, a secret variable, a resource and a schedule.
Fixtures are created before the operator logs in — AUTH_CACHE
(windmill-api-auth/src/auth.rs:40) memoizes the resolved Authed, so a token minted
before a folder grant reports the old folder list and every read spuriously 404s.
Roughly 110 endpoints were exercised with the operator token and the same set with the
member token, recording status and body. The scripts live in the session scratchpad
(setup.sh, probe.sh); they are throwaway harnesses, not committed.
The runtime probe only covers the compiled feature set, which for a CE dev backend is
quickjs alone. Anything behind a cargo feature was read rather than called: no trigger
feature is compiled, so the trigger-CRUD row in §5 rests on
windmill-trigger/src/handler.rs; oauth2_ee needs oauth2 + private
(windmill-api/src/lib.rs:132), so §3's OAuth row rests on the EE sources. Re-run the audit
against an EE build before treating §5's ✅/❌ as exhaustive for a feature you care about.
The cheap re-check that does not need a running backend: diff
grep -rn 'authed\.is_operator' backend/ --include=*.rs (from a checkout with
windmill-ee-private linked) against §3's table.