diff --git a/AGENTS.md b/AGENTS.md index e39d4ebede..67a6315644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/operator-capabilities.md b/docs/operator-capabilities.md new file mode 100644 index 0000000000..e032e9d007 --- /dev/null +++ b/docs/operator-capabilities.md @@ -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/) + 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.