Files
windmill/docs/auth-surface.md
T
e7fc1b2e2e feat: restricted job tokens per script and flow (#11484)
* feat: restricted job tokens (job_token_scopes on scripts and flows)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: admit flow-run reads, skip dedicated workers, gate on worker version

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep restricted jobs off flow runners, preserve scopes on rename and promotion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep restricted jobs off every dedicated handoff, confine progress flow id

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: exclude restricted runnables from dedicated worker startup

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: gate restrictions on the release after 1.821.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store per-job scopes on job_perms instead of v2_job, pin inline runs to the checked version

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: step-level job_token_scopes for flow steps and agent tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: fail closed on perms read errors, refuse restricted queue imports, gate step scopes in previews

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: carry a job's scopes on its completion so a re-run keeps the caller's cap

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: carry a zombie job's scopes into its completion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: leave a zombie for the next sweep when its scopes cannot be read

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* docs: correct the QueuedJobV2 completion comment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: validate step scopes in batch flows, fail closed on unvalidated step scopes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: refuse flows with step or tool restrictions at push while an older worker is live

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: apply the step-scope worker gate to flow restarts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: list the job token toggle with the other step and flow settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* chore: pin the EE companion merged with EE main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* perf: skip scope lookups for unrestricted jobs; list job token setting last

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* style: rustfmt scopes tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: confine restricted job tokens to their own run lineage; drop remaining extra lookups

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* chore: update ee-repo-ref to 259ad3bfeef5285ba80eedc86309b11dca001220

This commit updates the EE repository reference after PR #843 was merged in windmill-ee-private.

Previous ee-repo-ref: 2b77c0225dca441235daf7bf0a06ba968df0c927

New ee-repo-ref: 259ad3bfeef5285ba80eedc86309b11dca001220

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-03 09:33:44 +02:00

156 lines
13 KiB
Markdown

# Auth surface: facts that are easy to get wrong
Symbols, not line numbers, are cited: they drift less.
- **Credential precedence** (`windmill-api-auth/src/auth.rs` `extract_token`): `Authorization: Bearer`
→ `token` cookie → `?token=` query param. A URL with `?token=` is a credential on every route, but
an existing cookie silently wins over it.
- **`AUTH_CACHE`** caches a token's identity for 120 s. Deleting a token row does not purge it: the
DB trigger (`migrations/20260316000001_token_hash_pk_swap.up.sql`) notifies only for
`label = 'session'` rows, and `delete_token` never calls `invalidate_token_from_cache`.
- **Sessions** are `token` rows with `label='session'` plus the HttpOnly `token` cookie, minted only
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
mints one for any non-job token but returns plain text, no redirect.
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
- **`max_token_expiration_days`** caps `POST /users/tokens/create` and `tokens/impersonate`, by
shortening the stored expiration (`cap_token_expiration`), never by refusing: the CLI
authorization page, `wmill user create-token` and the editor's language-server token all pick a
lifetime without reading the setting, and CLIs already installed never will. The CLI signs in
again on its own when its token expires, which is why the authorization page labels it
`cli-login:<username>`, reserved in `is_user_token` so its expiry does not email the user. A token
owned by a service account is exempt: one in the workspace the token names, or in any workspace
for a workspace-less token (for `tokens/impersonate`, the impersonated account). Any workspace
admin can therefore create and impersonate a service account to hold an uncapped token, so the
ceiling bounds personal tokens only. Only the stored expiration is capped: the auth lookup never
reads the setting, so tokens that exist when it is turned on or lowered keep theirs, including
none. Deliberately outside it: server-side mints (`create_token_internal` callers such as native
trigger webhook tokens, which never expire for GitHub and Nextcloud), and tokens with their own
fixed lifetime that outlive a short ceiling: sessions (`MAX_SESSION_VALIDITY_SECONDS`, 3 days, and
re-mintable through `GET /users/refresh_token`) and MCP OAuth access tokens (7 days, with a
rotating 30-day refresh token). Any logged-in user can read the setting through `GET
/settings/global/{key}`, which the token form uses to offer only expirations within it. The
settings API and config sync reject any value `parse_max_token_expiration_days` cannot read, since
the token routes would read it as no ceiling; `parseMaxTokenExpirationDays` in the frontend must
accept exactly the same values.
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items` removes
an expired `token` row, the monitor emails the owner and raises a critical alert (if enabled);
rows registered by `register_token_expiry_notification` also get an "expiring soon" warning first,
except a token whose whole lifetime fits in `TOKEN_EXPIRY_WARNING_DAYS` (7), which gets no row
since the warning would arrive minutes after it was created. Neither happens when `is_user_token`
(`windmill-common/src/auth.rs`) reserves the label, so a token the system mints for itself,
whether from the backend or from the frontend through `tokens/create`, needs a reserved label. An
`ephemeral-` prefix needs no other change (keep it clear of `is_server_minted_label` if minted
through `tokens/create`); a new prefix also goes into the SQL and Svelte mirrors that function's
doc lists.
- **Every superadmin route refuses a job token**: `require_super_admin`
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes.
- **Write access to a folder steers its readers' AI chat.** An `ai_instruction` resource's body is
delivered to the global chat of everyone who can read the resource, with no opt-in, whenever the
chat touches a path under its folder (`copilot/chat/folderInstructions.ts`); the chat then acts
with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play
for every reader until turned off. The resource ACL is the only control, so granting write on a
folder grants that influence.
- **Restricted job tokens** (`job_token_scopes` on scripts and flows): a job's effective scopes
are stored on its `job_perms` row at push and minted into its token
(`create_token_for_owner`); an empty set is minted as `NO_API_ACCESS_SCOPE`, since
`scopes: Some([])` reads as unscoped. Every `push` takes a `scope_ceiling`, and the job gets the
ceiling ∩ the target's own setting: flow steps take their flow job's, agent tools their agent's,
WAC children their parent's, retries the run they replace, and API pushes the calling job's
(`caller_scope_ceiling`) — a scoped user or webhook token caps nothing. The row is swept once a
job leaves the queue, so a restart of a completed flow takes the flow's current setting. Schedules,
triggers and error/success handlers start from the target's own setting. A restricted token
keeps only the runtime routes about its own job (`is_own_job_runtime_route`) and the reads of
its own flow run (`flow_run_read_route_job`, checked against its lineage: the orchestrator
evaluates a step's `results.x` with the token of the step that just finished). That lineage is
trusted, so a restricted job token, even an admin's, can only place a job it starts in its own
run (`unclaimable_run_lineage`); a route that
authenticates a token itself instead of through the route layer must call
`check_job_token_scope`. A restricted job never runs on a dedicated worker, which uses its own
unscoped token for every job. `$var:`/`$res:` args are resolved through the API with the job's
own token, so they need read scopes. A deploy that omits the setting keeps the deployed value
and `null` clears it, so an unaware client cannot drop a restriction; setting one is refused
until every worker supports it, since an older worker mints without it. The setting belongs to
the deployed version: an older script hash run by hash runs with that version's setting (a
restricted caller still caps it). Write scopes on scripts, flows, schedules or triggers let a
job escape its restriction.
- **A remote deploy token is a credential for another instance**, held per account and workspace
(`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`).
Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or
renames an account takes the token along and a recycled address cannot inherit it; removal from
a workspace deletes it explicitly (`delete_workspace_user_internal`, both `leave_workspace`). The
row records the target it was granted for, and
`remote_deploy::proxy` only sends it to a target still matching the workspace setting, so
re-pointing the setting cannot redirect anyone's token to a URL of the admin's choosing.
`require_own_credentials` refuses job, scoped and read-only tokens (on `set_target` too): the
stored token carries none of their restrictions. The proxy turns the local session into a remote
bearer credential, so every ambient-cookie vector becomes one on the remote: its URL carries the
row's random `proxy_key` (a link riding the `SameSite=Lax` cookie cannot know it; a header would
do, but the frontend's only per-call hook is the global `OpenAPI.HEADERS`, whose mere presence
switches every download to in-memory blobs). The key is served `no-store`, withheld from the
credentials `require_own_credentials` refuses, and masked in this instance's own request logs
(`RedactedUri`, used by the request span and the log context); a reverse proxy in front still
writes the full path to its access log. `connect` names the target the token was obtained for
and is refused, before the token is sent anywhere, if the workspace now points elsewhere; the
token only ever goes to that target. It takes no lock against what clears these rows (a removal
from the workspace, a target change, a key rotation): their writers take the account, membership,
key and settings rows in every order, so any lock held there could close a deadlock. A row can
therefore land after one of them ran, however long its remote call took, and every read
(`load_connection`) voids it instead, by identity rather than by comparing times: the connect
records, before its remote call, the target setting's version (`remote_deploy_target_changed_at`)
and the `created_at` of the owner's membership, and the row counts only while both are still
the same (or the owner is a superadmin) and it decrypts. So a target set A → B → A, or a
removal and re-add, voids it whatever the clocks say. A superadmin with no membership is bound
through the credential making the request instead: the insert requires its `token` row to
still exist, and `delete_user`, offboarding and SCIM removal delete an account's tokens, so an
address deleted that way and re-created during the call does not inherit the connection (a JWT,
having no row, cannot connect a workspace its owner is not a member of). `leave_instance`
deletes only the `password` row, leaving the tokens and the memberships, which is not covered.
Neither is a member's connect across an account re-creation: an address is not expected to pass
to another person. A disconnect empties and stamps the row
(inserting one if needed) rather than deleting it, `connected_at` is when a connect started,
and the connect's upsert leaves a row stamped after that alone, so an older connect cannot undo
a disconnect or a newer connect. Connecting by redirect: the remote's
`/user/remote_deploy_authorize` page
mints a token bound to the one remote workspace (`remote-deploy:<source host>`, a label
reserved in `is_user_token` so its expiry emails nobody) only on an
explicit Authorize, only for a callback whose path is `/remote_deploy/callback`, and refuses to
render inside a frame; the token travels in the fragment, and the callback checks a single-use
`state` the drawer stored, so no other page can plant a token as the user's. The proxy also refuses a path the URL parser would rewrite,
serves every response under `CSP: sandbox` + `nosniff`, forwards only the method, query, body,
content-type and accept — never this instance's cookie or token — and turns the target's 401
into a 502, because the browser logs the user out of *this* instance on an unhandled 401.
- **`login_type`** (`password` table) is a free-form `VARCHAR(50)`. Password login and password
reset require `login_type = 'password'`; `set_password` also accepts `pending_oauth` and turns
the account into a `password` one in the same statement (an account created ahead of its owner
gets its first credential that way, or through the OAuth claim below).
- **Login links** (`login_link` table, `POST /users/login_links` superadmin-only,
`GET /auth/login_link/{token}` unauthenticated): single-use, ≤15 min, a session cookie and a
302 to a same-origin `rd`. `require_login_type` on the mint refuses (409) an account whose
`login_type` has moved on — the way a caller re-entering an account it created stops being
able to once the owner has a password or a provider.
- **Pre-approved trial offer** (`cloud_trial_offer`, cloud-only routes under
`/users/cloud_trial_offer`): written by a superadmin at provisioning, consumed by
`{consumed: true}` or by the portal's refusal; `…/go` is the one Windmill→portal hop that
mints a portal login, over the same `CUSTOMER_SERVICE_TOKEN` trust the onboarding hook uses
(`users_ee.rs`, the portal's admin token). It never expires on its own.
- **OAuth login** (`oauth2_ee.rs` `login_externally`, decision in `existing_login_decision`)
matches an existing account by lowercased email only. Same provider → login; a
`pending_oauth` account (see `PENDING_OAUTH_LOGIN_TYPE`) is **claimed** by the first login
whose address the provider itself asserted and did not mark unverified — `login_type` becomes
the client key and the hash is nulled; otherwise `require_preexisting_user_for_oauth` decides:
on, *every* existing account is loggable-into by any provider; off, "exists but with a
different login type". A new account gets `login_type = <client key>`.
- **OAuth email trust**: `LoginUserInfo.email_verified` is read leniently (bool or
"true"/"false" strings) and is only consulted for the claim above; only GitHub is filtered to
`primary && verified`; a missing email is fabricated from `name` as `<name>@windmill.dev` and
reaches `login_externally` with `email_asserted = false`.
- **`GET /api/oauth/login/{client}`** is an unauthenticated 302 to the provider — a plain link
from any page starts SSO.
- **`CLOUD_HOSTED`** is presence-tested (`windmill-common/src/worker.rs`): `CLOUD_HOSTED=false`
still enables cloud mode. Of the routes above only the cloud trial offer and onboarding
profile routes are cloud-gated; for the rest, cloud only adds quotas.
- **`CREATE_WORKSPACE_REQUIRE_SUPERADMIN`** defaults to `true` when unset; only the literal
`"true"` enables it when set.