mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
docs: audit what an operator's token can reach via the API
This commit is contained in:
@@ -17,6 +17,8 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
|
||||
`cargo run`; a normal build cannot start one at all.
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Operator role**: `docs/operator-capabilities.md` — what an operator's token can reach, where
|
||||
the boundary is enforced, and why `operator_settings` is not an authorization check
|
||||
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
|
||||
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
|
||||
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# 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_context` receives
|
||||
`is_admin`, username, groups, and the read/write folder lists — not `is_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 ~28 request handlers.**
|
||||
Anything not on that list is reachable. The workspace's `operator_settings` is *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 | always `is_admin = true`, `is_operator = false` (`auth.rs:391`) |
|
||||
| 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` ... 28 hard denials (§3) + 14 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`.
|
||||
|
||||
| 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` |
|
||||
|
||||
The common thread is **arbitrary request-supplied code** and **deploying runnables**. Nothing else.
|
||||
|
||||
## 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/list` returns `[]`
|
||||
(`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/list` is forced to `kind = 'script'` (`scripts.rs:309`), hiding failure/trigger/
|
||||
approval/preprocessor scripts **from the list only**.
|
||||
- `/debug` omits `registry_config` (`windmill-api-debug/src/lib.rs:495`).
|
||||
|
||||
> Verified: a `kind: failure` script is absent from an operator's `/scripts/list`, yet
|
||||
> `GET /scripts/get/p/f/shared/fail` returns it with full source (`200`) and
|
||||
> `POST /jobs/run/p/f/shared/fail` runs 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
|
||||
|
||||
1. **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.
|
||||
2. **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.
|
||||
3. **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.
|
||||
4. **Reading secrets is within the baseline.** `variables/get_value` and interpolated
|
||||
resources already return plaintext secrets to an operator with ACL. Redaction is a
|
||||
product decision, not a permission one.
|
||||
5. **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.
|
||||
6. **`operator_settings` cannot be used to justify exposing less or more.** Filter tools with
|
||||
it for UX consistency, but derive the security boundary from §3–§5.
|
||||
|
||||
## 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.
|
||||
|
||||
Known gap: no trigger cargo feature is compiled into the dev backend, so the trigger-CRUD
|
||||
row in §5 rests on reading `windmill-trigger/src/handler.rs` rather than on a live call.
|
||||
Reference in New Issue
Block a user