Files
windmill/backend/tests/wm_token_confinement.rs
T
hugocasaandClaude Opus 5 9022dc9d44 fix: confine job tokens to workspace-scoped API routes (#10631)
* fix: confine job tokens to workspace-scoped API routes

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

* fix: keep the object-storage connection test reachable from a job token

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

* fix: keep the workspace-exists check the CLI makes reachable from a job

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

* test: reconcile the job-token caps after #10124

The workspace-confinement middleware answers a workspace-less route before the
privilege gate behind it runs, so the cases #10124 added on those routes now see
403 rather than 401. Rejection is what they assert, but two of them needed more
than a status change:

- `list_worker_groups` asserted only that the response body omits the static env
  value, which an error body satisfies for the wrong reason. It now asserts the
  status, keeping the secret check as a second assertion.
- `require_super_admin` lost its only unshadowed route. `GET
  /api/w/{workspace}/users/list_addable` is gated solely by that call and names a
  workspace, so it reaches the gate and pins it at 401.

The module doc states the two-layer rule once; the file covers both caps, so it
is no longer named for either one alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: correct the workspaces/exists rationale and the parquet gate note

`workspace` carries no row-level security, so `exists_workspace` running through
`user_db` does not filter by membership as the comment claimed. State what the
route actually discloses — whether a workspace id is taken.

The object-storage case explained why a 404 would satisfy the assertion for the
wrong reason, which described the earlier `assert_ne!(403)`; against the 422 it
now asserts, a 404 fails. Say instead why the case is gated on the feature.

* fix: let a job token keep the workspace-less routes that carry no workspace

Confinement refused every route outside the allowlist, including ones that
answer purely from the caller's own account or from the request body. Those
cross no workspace boundary, so refusing them buys nothing:

- `users/email` returns a value already inside the token, and
  `workspaces/allowed_domain_auto_invite` tests the caller's own address against
  a static list. Neither opens a transaction.
- `users/usage` reads the caller's own row; `users/tutorial_progress` reads and
  upserts a UI bitfield keyed on the same email.
- `schedules/preview` takes no `ApiAuthed` at all — it computes the occurrences
  of the cron expression in the body and returns nothing the caller did not send.

The rule, not the list, is what the doc comment states: answers from the caller's
own account, the request body, or content identical for every workspace; never
naming another workspace, never instance configuration. The candidates it
excludes are written down with their reasons, since `users/list_invites` reads as
caller-scoped until you notice the response carries a workspace id per invite.

Regression covers both directions — the new entries answer, and the rejected
caller-scoped reads stay refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: reach the privilege gates confinement hides

`require_devops_role` and `require_instance_admin` gate only workspace-less
routes, so confinement answers every request that would reach them: no HTTP case
can tell whether they still cap job tokens, and both could lose that check with
this suite green. `require_super_admin` has a workspace-scoped route to reach it;
these two have none, so call them directly instead.

The identity used is the fixture's real superadmin, so the passing half proves
the rejection keys off `job_id` rather than off the user.

Also separate two claims the write-allowlist doc had merged into one sentence:
no entry writes outside the caller's own account, but what each may read differs,
and the workspace-existence check answers for any id.

* docs: say that the object-storage probe writes

The write-allowlist lead claimed no entry writes state outside the caller's own
account. `test_s3_bucket` puts an object into the store the body names and
deletes it again, so it does write; a failure between the two leaves the object
behind. The invariant that holds is about Windmill state.

Say so in the lead, and describe the put/delete in the entry itself rather than
leaving "acts only on the store the request body describes" to imply a read.

* test: cover the last two job-token gates confinement hides

Seven guards key on `ApiAuthed::job_id`. Three keep a workspace-scoped route and
are exercised over HTTP; the other four are reachable only through workspace-less
routes, which confinement now answers first, so nothing observed whether they
still cap job tokens.

`require_devops_role` and `require_instance_admin` were already called directly.
Add the two that were not: `forbid_superadmin_job_token`, and
`forbid_elevated_job_token`, whose call sites are `create_token`,
`update_token_scopes` and `set_password` — all workspace-less.

Both key on two conditions rather than one, so all three combinations are pinned:
neither fires without job provenance, and neither fires for an unelevated
identity. The second matters — collapsing either into a blanket job-token refusal
would stop ordinary users creating tokens, and no other case would catch it.

The doc comment records which of the seven each route covers.

* docs: correct which job-token gates have no observable route

The previous commit put `forbid_elevated_job_token` among the guards reachable
only through workspace-less routes, and its message named three call sites. It
has six, and two are workspaced: `mint_app_embed_token` and
`mint_raw_app_sdk_token`. Its superadmin branch is therefore already exercised
over HTTP — the 401 the embed-token case asserts is this gate.

So three of the seven lack an observable route, not four. Its direct assertions
stay: the embed-token case only ever reaches it with an elevated identity, and
the unelevated-negative case is what would catch the gate being collapsed into a
blanket job-token refusal.

* test: pin is_instance_admin, and stop enumerating gates in prose

`is_instance_admin` is `authed.is_admin && authed.job_id.is_none()`, so a census
built by searching for `job_id.is_some()` could not see it. Both its call sites
are workspace-less, and it returns a bool that selects obfuscation rather than
refusing — a job token reading `true` leaks `env_vars_static` instead of being
turned away. Pin both directions.

The doc comment tried to account for every job-token guard and which route
exercised it. It was wrong three times running: the count, the call sites of
`forbid_elevated_job_token`, and the claim that the CUSTOM_INSTANCE_DB case
covers `is_super_admin_authed` when that path tests `job_id` inline. A table that
has to be rederived from six crates to stay true does not belong in a comment, so
it now states only why these calls are direct.

* docs: name the right is_instance_admin caller

The comment credited "the concurrency-group listing" with obfuscating rows. The
second caller is `prune_concurrency_group`, which returns PermissionDenied; the
obfuscating one is `list_worker_groups`. Keep the claim to that single caller,
which is what makes this guard fail by leaking `env_vars_static` rather than by
admitting a request.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:55:42 +02:00

1168 lines
39 KiB
Rust

//! A WM_TOKEN (job JWT) is minted for one job in one workspace and carries that
//! job's full user privileges. Two independent caps hold it there, and this file
//! covers both:
//!
//! - Confinement to the job's workspace, enforced in the auth middleware. A route
//! that names no workspace is instance-wide, so reaching one would trade an
//! ephemeral, workspace-bound credential for a permanent one (`tokens/create`
//! mints a workspace-less API token that never expires), for instance
//! configuration, or for global user management. Refusals are `403`.
//! - A ceiling of workspace admin whatever identity the token borrows, enforced at
//! the privilege gates themselves (`require_super_admin`, `require_devops_role`,
//! `require_instance_admin`, GHSA-hfh4-cx4h-3fcr). Refusals are `401`.
//!
//! The middleware runs first, so on a workspace-less route it answers before the
//! gate behind it ever runs: those cases pin the outer cap, and the gates are
//! pinned by the workspace-scoped cases, which the middleware lets through.
//!
//! A non-admin `wm_deployers` member can mint such a token implicitly via an
//! app/flow `on_behalf_of`, so the identity it carries need not be their own. A
//! real superadmin who needs a global endpoint from a script must use a
//! dedicated API token (which only a real superadmin can create), not
//! `$WM_TOKEN`.
//!
//! The fixture provides `test@windmill.dev` (instance superadmin, token
//! `SECRET_TOKEN`) and `test2@windmill.dev` (non-superadmin, `SECRET_TOKEN_2`).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_api_auth::ApiAuthed;
use windmill_common::auth::create_jwt_token;
use windmill_common::db::Authed;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Mint a WM_TOKEN: an internally-signed job JWT (note the `job_id` claim) for
/// `email`, exactly as a running app/flow job is issued.
async fn wm_token(email: &str, is_admin: bool) -> String {
let authed = Authed {
email: email.to_string(),
username: "runner".to_string(),
is_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: None,
token_prefix: None,
};
create_jwt_token(
authed,
"test-workspace",
3600,
Some(uuid::Uuid::new_v4()),
Some("app".to_string()),
None,
None,
)
.await
.expect("mint wm_token")
}
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_is_confined_to_its_workspace(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// The server decodes WM_TOKENs with the same in-process JWT secret, so
// setting it once lets us mint a valid one below.
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api");
let base = format!("{api}/users");
// A superadmin-capable WM_TOKEN — the exact thing a deployer obtains via an
// app on_behalf_of pointed at a superadmin.
let sa_wm = wm_token("test@windmill.dev", true).await;
// ...and one for a plain user: neither may leave its workspace.
let user_wm = wm_token("test2@windmill.dev", false).await;
// 1. Cannot mint a (superadmin) token.
let resp = authed(client().post(format!("{base}/tokens/create")), &sa_wm)
.json(&json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not create tokens: {}",
resp.text().await?
);
// 2. Cannot impersonate (mint a token as another user).
let resp = authed(client().post(format!("{base}/tokens/impersonate")), &sa_wm)
.json(&json!({ "impersonate_email": "test2@windmill.dev" }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not impersonate: {}",
resp.text().await?
);
// 3. Cannot promote a user to superadmin.
let resp = authed(
client().post(format!("{base}/update/test2@windmill.dev")),
&sa_wm,
)
.json(&json!({ "is_super_admin": true }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not promote users: {}",
resp.text().await?
);
// 4. Cannot reset its own (the superadmin's) password.
let resp = authed(client().post(format!("{base}/setpassword")), &sa_wm)
.json(&json!({ "password": "hunter2" }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not reset passwords: {}",
resp.text().await?
);
// 4b. Cannot delete a user.
let resp = authed(
client().delete(format!("{base}/delete/test2@windmill.dev")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not delete users: {}",
resp.text().await?
);
// 4c. Cannot change a user's login type.
let resp = authed(
client().post(format!("{base}/set_login_type/test2@windmill.dev")),
&sa_wm,
)
.json(&json!({ "login_type": "password" }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not change login type: {}",
resp.text().await?
);
// 4d. Cannot offboard a global user (deletes user, tokens, password, invites,
// instance-group membership and reassigns their assets).
let resp = authed(
client().post(format!("{base}/offboard/test2@windmill.dev")),
&sa_wm,
)
.json(&json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not offboard users: {}",
resp.text().await?
);
// 4e. Cannot export the global user table (leaks every user's password_hash).
let resp = authed(client().get(format!("{base}/export")), &sa_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not export global users: {}",
resp.text().await?
);
// 5. Not only user management: any workspace-less route is out of reach,
// including one open to every authenticated user.
let resp = authed(client().get(format!("{api}/workers/list")), &user_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"WM_TOKEN must not enumerate instance workers: {}",
resp.text().await?
);
// 6. A plain user's WM_TOKEN cannot mint itself a permanent, workspace-less
// token either — the confinement does not depend on being a superadmin.
let resp = authed(client().post(format!("{base}/tokens/create")), &user_wm)
.json(&json!({ "label": "from-script" }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"WM_TOKEN must not create tokens: {}",
resp.text().await?
);
// 7. Escape hatch / no false positive: a real API token (SECRET_TOKEN, no
// job_id) still reaches both.
let resp = authed(
client().post(format!("{base}/tokens/create")),
"SECRET_TOKEN",
)
.json(&json!({ "label": "ci" }))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"a real superadmin token must still create tokens: {}",
resp.text().await?
);
let resp = authed(client().get(format!("{api}/workers/list")), "SECRET_TOKEN")
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real token must still list workers: {}",
resp.text().await?
);
// 8. No collateral on the routes a job legitimately needs: its own workspace,
// and the workspace-less endpoint the clients' `whoami()` calls.
let resp = authed(client().get(format!("{base}/whoami")), &user_wm)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still resolve its own identity: {}",
resp.text().await?
);
let resp = authed(
client().get(format!("{api}/w/test-workspace/scripts/list")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still work inside its own workspace: {}",
resp.text().await?
);
// 9. ...and the writes it keeps. `wmill workspace add` checks this before it will
// accept the credentials it was given, so a job that points the CLI at its own
// instance depends on it.
let resp = authed(client().post(format!("{api}/workspaces/exists")), &user_wm)
.json(&json!({ "id": "test-workspace" }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still reach the workspace-exists check `wmill workspace add` makes: {}",
resp.text().await?
);
// The resource editor's object-storage "Test connection" runs as a preview job that
// POSTs its config here. The body is deliberately not a valid `ObjectSettings`, so
// reaching the handler's own extractors is exactly a 422 — a 403 means confinement
// refused it. The route is only mounted under `parquet`, which would make this a 404,
// so the case is gated on the feature rather than left to fail where it can't run.
#[cfg(feature = "parquet")]
{
let resp = authed(
client().post(format!("{api}/settings/test_object_storage_config")),
&user_wm,
)
.json(&json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
422,
"WM_TOKEN must still reach the object-storage connection test: {}",
resp.text().await?
);
}
// 10. The rest of the allowlist: routes that answer from the caller's own account or
// from the request body alone.
for route in [
"users/email",
"users/usage",
"users/tutorial_progress",
"workspaces/allowed_domain_auto_invite",
] {
let resp = authed(client().get(format!("{api}/{route}")), &user_wm)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still read its own {route}: {}",
resp.text().await?
);
}
let resp = authed(client().post(format!("{api}/schedules/preview")), &user_wm)
.json(&json!({ "schedule": "0 0 12 * * *", "timezone": "UTC" }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still preview a cron expression: {}",
resp.text().await?
);
let resp = authed(client().post(format!("{base}/tutorial_progress")), &user_wm)
.json(&json!({ "progress": 1, "skipped_all": false }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still record its own tutorial progress: {}",
resp.text().await?
);
// 11. ...and the caller-scoped reads deliberately left out of it, each because it
// names another workspace or the identity's credentials.
for route in ["users/list_invites", "users/tokens/list", "workspaces/list"] {
let resp = authed(client().get(format!("{api}/{route}")), &user_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"{route} must stay confined: {}",
resp.text().await?
);
}
Ok(())
}
/// A WM_TOKEN running as a superadmin must be rejected by *any* `require_super_admin`
/// route, not just the handful that call `forbid_superadmin_job_token`
/// (GHSA-hfh4-cx4h-3fcr). Both shapes of such a route are covered: a workspace-less
/// one, which workspace confinement answers first, and a workspace-scoped one, which
/// reaches the gate itself.
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_rejected_by_require_super_admin(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
// The exact token a deployer obtains via an app on_behalf_of pointed at a superadmin.
let sa_wm = wm_token("test@windmill.dev", true).await;
let resp = authed(client().get(format!("{base}/settings/list_global")), &sa_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not reach a require_super_admin route: {}",
resp.text().await?
);
// No false positive: a real superadmin API token (no job_id) still reaches it.
let resp = authed(
client().get(format!("{base}/settings/list_global")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real superadmin token must still reach the route: {}",
resp.text().await?
);
// `GET /api/w/{workspace}/users/list_addable` is gated solely by
// `require_super_admin` too, but names a workspace, so the request runs the gate
// instead of stopping at confinement.
let resp = authed(
client().get(format!("{base}/w/test-workspace/users/list_addable")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not clear require_super_admin inside its own workspace: {}",
resp.text().await?
);
let resp = authed(
client().get(format!("{base}/w/test-workspace/users/list_addable")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real superadmin token must still clear the gate: {}",
resp.text().await?
);
Ok(())
}
/// Direct `is_super_admin_email` authorization gates (not routed through
/// `require_super_admin`) must also reject a superadmin `WM_TOKEN`. Covers the two
/// bypass classes the CI review flagged: destructive `delete_workspace`, and the
/// `CUSTOM_INSTANCE_DB` credential lookup whose guard must read the *authenticated*
/// `job_id`, not the caller-supplied `?job_id` query param (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_rejected_by_direct_super_admin_gates(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let sa_wm = wm_token("test@windmill.dev", true).await;
// 1. Global workspace deletion (destructive) — must be forbidden.
let resp = authed(
client().delete(format!("{base}/workspaces/delete/test-workspace")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not delete a workspace: {}",
resp.text().await?
);
// The workspace must still exist.
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'test-workspace')")
.fetch_one(&db)
.await?;
assert!(
exists,
"rejected delete must not have removed the workspace"
);
// 2. CUSTOM_INSTANCE_DB credential lookup, WITHOUT the ?job_id query param —
// the guard must reject based on the authenticated token's job_id.
let resp = authed(
client().get(format!(
"{base}/w/test-workspace/resources/get_value_interpolated/CUSTOM_INSTANCE_DB/anydb"
)),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not resolve CUSTOM_INSTANCE_DB (no creds leak): {}",
resp.text().await?
);
Ok(())
}
/// The instance-level `devops` role must be capped like superadmin.
/// `is_devops_email` returns true for superadmin emails, so every
/// `require_devops_role` route (worker management, instance config, service logs)
/// is reachable by exactly the same superadmin `WM_TOKEN` unless it is capped too
/// (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_rejected_by_require_devops_role(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let sa_wm = wm_token("test@windmill.dev", true).await;
let resp = authed(
client().get(format!("{base}/service_logs/list_files")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not reach a require_devops_role route: {}",
resp.text().await?
);
// No false positive: a real superadmin API token (no job_id) still reaches it.
let resp = authed(
client().get(format!("{base}/service_logs/list_files")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real superadmin token must still reach the devops route: {}",
resp.text().await?
);
// The advisory's own PoC route: the full user directory, gated solely by
// `require_super_admin` with no per-route job-token denylist.
let resp = authed(
client().get(format!("{base}/users/list_as_super_admin")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not list all users: {}",
resp.text().await?
);
Ok(())
}
/// A job token must not clear an *admin-or-devops* gate via the devops branch.
/// `require_admin_or_devops` (the EE critical-alerts endpoints) grants when the
/// caller is a workspace admin OR an instance `devops`; since `is_devops_email`
/// is true for superadmins, a WM_TOKEN running on-behalf of a superadmin who is
/// NOT a member of the target workspace would otherwise gain workspace-scoped
/// devops access to a workspace it has no admin rights in (GHSA-hfh4-cx4h-3fcr).
/// The workspace-admin branch stays allowed — that is the cap ceiling.
#[cfg(feature = "enterprise")]
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_rejected_by_admin_or_devops_gate(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// superadmin-external is a superadmin but not a member of test-workspace, so
// its workspace-level is_admin is false — the exact exploit precondition.
let sa_wm = wm_token("superadmin-external@windmill.dev", false).await;
let resp = authed(client().get(format!("{base}/critical_alerts")), &sa_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not clear the admin-or-devops gate on a workspace it isn't admin of: {}",
resp.text().await?
);
// No false positive: the same superadmin's real API token (not a job token)
// still clears the gate via the devops branch.
let resp = authed(
client().get(format!("{base}/critical_alerts")),
"EXTERNAL_SUPERADMIN_TOKEN",
)
.send()
.await?;
assert_ne!(
resp.status(),
403,
"a real superadmin token must still clear the admin-or-devops gate: {}",
resp.text().await?
);
Ok(())
}
/// Instance-global routes with no workspace binding that gate on the caller's own
/// `is_admin` claim must reject a WM_TOKEN — `is_admin` is a workspace-admin claim
/// (also true for superadmins), and a job token is capped at workspace admin, so it
/// must not wield that claim as instance authorization (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_rejected_by_instance_admin_gates(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
// A worker-group config carrying a static env value that must stay masked.
sqlx::query("INSERT INTO config (name, config) VALUES ('worker__wm2082grp', $1)")
.bind(json!({ "env_vars_static": { "LEAKY": "supersecretvalue" } }))
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
// The exact token a deployer obtains via an app on_behalf_of pointed at a
// superadmin: is_admin=true, but carrying a job_id.
let sa_wm = wm_token("test@windmill.dev", true).await;
// 1. Arbitrary workspace unarchive (mutation on any workspace by id).
let resp = authed(
client().post(format!("{base}/workspaces/unarchive/test-workspace")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not unarchive an arbitrary workspace: {}",
resp.text().await?
);
// 2. Global concurrency-group pruning.
let resp = authed(
client().delete(format!("{base}/concurrency_groups/prune/anykey")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not prune a global concurrency group: {}",
resp.text().await?
);
// 3. The sibling listing spans every workspace's concurrency keys, so it is
// gated the same way as the prune above.
let resp = authed(
client().get(format!("{base}/concurrency_groups/list")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not list global concurrency groups: {}",
resp.text().await?
);
// 4. Worker-group config: refused outright, so the static env value it would
// otherwise obfuscate never reaches a job token. Asserting the status rather
// than the absence of the secret keeps the case honest — an error body
// trivially satisfies "does not contain the secret".
let resp = authed(
client().get(format!("{base}/configs/list_worker_groups")),
&sa_wm,
)
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 403,
"superadmin WM_TOKEN must not read the worker-group config: {body}"
);
assert!(
!body.contains("supersecretvalue"),
"the refusal must not carry the static env value: {body}"
);
// No false positive: a real superadmin API token (no job_id) still sees the
// unobfuscated value — the cap keys off the job token, not the identity.
let body = authed(
client().get(format!("{base}/configs/list_worker_groups")),
"SECRET_TOKEN",
)
.send()
.await?
.text()
.await?;
assert!(
body.contains("supersecretvalue"),
"a real superadmin token must still see the unobfuscated worker-group config: {body}"
);
Ok(())
}
/// Capping a `WM_TOKEN` at the gates is only durable if the token cannot trade
/// itself for one without the `job_id` those gates key off. Both credential-minting
/// routes must therefore refuse an elevated job token: `refresh_token` (which mints
/// a database-backed session token and returns it in `Set-Cookie`) and
/// `tokens/create` for the `devops` tier, whose routes are capped just like
/// superadmin's (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_cannot_mint_a_provenance_free_credential(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/users");
// 1. Session refresh: a superadmin-identity job token must not obtain a session
// token, which would authenticate with no job provenance at all.
let sa_wm = wm_token("test@windmill.dev", true).await;
let resp = authed(client().get(format!("{base}/refresh_token")), &sa_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not refresh into a session token: {}",
resp.text().await?
);
// No false positive: a real superadmin API token still refreshes.
let resp = authed(
client().get(format!("{base}/refresh_token")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real superadmin token must still refresh: {}",
resp.text().await?
);
// 2. Token mint, devops tier: `require_devops_role` rejects this job token, so
// minting one that would pass it by email must be refused too.
let devops_wm = wm_token("devops@windmill.dev", false).await;
let resp = authed(client().post(format!("{base}/tokens/create")), &devops_wm)
.json(&json!({ "label": "from-script" }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"devops WM_TOKEN must not mint a token: {}",
resp.text().await?
);
// 3. Choosing the password of the elevated account it runs as would let the
// holder log in for a session that carries no job provenance at all.
let resp = authed(client().post(format!("{base}/setpassword")), &devops_wm)
.json(&json!({ "password": "hunter2" }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"devops WM_TOKEN must not set its account password: {}",
resp.text().await?
);
Ok(())
}
/// The MCP OAuth approval is a third credential mint: the code it stores is
/// exchanged for a database token holding only an email, so an elevated job token
/// approving a client would obtain a credential with no `job_id` and re-enter the
/// API through the gateway uncapped (GHSA-hfh4-cx4h-3fcr). The guard sits in the
/// shared inner fn, ahead of client validation, so it fires without a registered
/// client.
#[cfg(feature = "mcp")]
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_cannot_mint_via_mcp_oauth_approval(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let approval = json!({
"client_id": "wm2082-client",
"redirect_uri": "http://localhost/callback",
"scope": "mcp:all",
"state": "s",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
});
let sa_wm = wm_token("test@windmill.dev", true).await;
let resp = authed(
client().post(format!("{base}/w/test-workspace/mcp/oauth/server/approve")),
&sa_wm,
)
.json(&approval)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not approve an MCP OAuth client: {}",
resp.text().await?
);
// The gateway route reaches the same mint and must be capped identically.
let mut gateway_approval = approval.clone();
gateway_approval["workspace_id"] = json!("test-workspace");
let resp = authed(
client().post(format!("{base}/mcp/gateway/oauth/server/approve")),
&sa_wm,
)
.json(&gateway_approval)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not approve through the MCP gateway: {}",
resp.text().await?
);
Ok(())
}
/// The two links that let a narrowly-scoped mint become a general credential: the
/// sandboxed app embed mint (a 12h database token with no job provenance) and
/// `tokens/update_scopes`, which an unscoped job token could use to clear the
/// scopes of any token sharing its email (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_cannot_mint_or_widen_an_app_embed_token(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
// A sandboxed app the superadmin identity can read — the mint's precondition.
sqlx::query(
"INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)
VALUES (9001, 'test-workspace', 'u/test-user/embedded', 'Embedded', '{}',
'{\"execution_mode\": \"viewer\", \"sandbox\": true}', '{}')",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO app_version (id, app_id, value, created_by, created_at)
VALUES (9001, 9001, '{\"grid\": []}', 'test-user', NOW())",
)
.execute(&db)
.await?;
sqlx::query("UPDATE app SET versions = ARRAY[9001::bigint] WHERE id = 9001")
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let sa_wm = wm_token("test@windmill.dev", true).await;
let resp = authed(
client().get(format!(
"{base}/w/test-workspace/apps/embed_token/p/u/test-user/embedded"
)),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not mint an app embed token: {}",
resp.text().await?
);
// Even a token minted some other way must stay narrow: widening is refused.
let resp = authed(
client().post(format!("{base}/users/tokens/update_scopes/SECRET_T")),
&sa_wm,
)
.json(&json!({ "scopes": serde_json::Value::Null }))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"superadmin WM_TOKEN must not widen a token's scopes: {}",
resp.text().await?
);
Ok(())
}
/// Destroying the account or credentials of the identity a job runs as is never the
/// runnable's work, and a `wm_deployers` member may point `on_behalf_of` at any real
/// user — so these reject every job token, elevated or not (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_cannot_destroy_its_on_behalf_account(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/users");
// An ordinary member's identity: the cap here does not depend on elevation.
let user_wm = wm_token("test2@windmill.dev", false).await;
let resp = authed(client().post(format!("{base}/leave_instance")), &user_wm)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"WM_TOKEN must not delete the account it runs as: {}",
resp.text().await?
);
// The prefix of that identity's real fixture token, so without the guard the
// delete would land rather than silently match nothing.
let resp = authed(
client().delete(format!("{base}/tokens/delete/SECRET_TOK")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"WM_TOKEN must not revoke that identity's tokens: {}",
resp.text().await?
);
// Ejecting the identity from a workspace is the same primitive, on both routes
// that expose it (one keyed by username, one by email).
for route in [
"w/test-workspace/users/leave",
"w/test-workspace/workspaces/leave",
] {
let resp = authed(
client().post(format!("http://localhost:{port}/api/{route}")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"WM_TOKEN must not leave a workspace as {route}: {}",
resp.text().await?
);
}
let membership: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(membership, 1, "the workspace membership must survive");
// The account and its credentials are untouched, not merely the response refused.
let account_rows: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'")
.fetch_one(&db)
.await?;
assert_eq!(account_rows, 1, "the password row must survive");
let token_rows: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM token WHERE email = 'test2@windmill.dev'")
.fetch_one(&db)
.await?;
assert!(token_rows > 0, "the identity's tokens must survive");
Ok(())
}
/// `load_workspace_authed` grants an admin claim in a workspace the caller may have
/// no relationship with, and carries `job_id` into the result — so deriving it from
/// the on-behalf email would hand a WM_TOKEN admin over every workspace on the
/// instance, and with it the cross-workspace diff (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_wm_token_gets_no_admin_claim_in_a_foreign_workspace(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
// A workspace the superadmin identity is not a member of.
sqlx::query(
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'Other', 'test-user')",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let sa_wm = wm_token("test@windmill.dev", true).await;
let resp = authed(
client().get(format!(
"{base}/w/test-workspace/workspaces/compare/other-workspace"
)),
&sa_wm,
)
.send()
.await?;
assert_ne!(
resp.status(),
200,
"superadmin WM_TOKEN must not diff a workspace it does not belong to"
);
// No false positive: a real superadmin token still holds the claim.
let resp = authed(
client().get(format!(
"{base}/w/test-workspace/workspaces/compare/other-workspace"
)),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real superadmin token must still diff across workspaces: {}",
resp.text().await?
);
Ok(())
}
/// The guards below cap a job token on routes that name no workspace, which confinement
/// now answers first — so no request can reach them and no HTTP case would notice if they
/// stopped capping. They are called directly for that reason; deleting them because the
/// suite is green elsewhere would leave the inner cap unpinned (GHSA-hfh4-cx4h-3fcr).
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_privilege_gates_reject_a_job_token_directly(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
fn authed_as(email: &str, job_id: Option<uuid::Uuid>) -> ApiAuthed {
ApiAuthed {
email: email.to_string(),
username: "runner".to_string(),
is_admin: true,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
job_id,
}
}
let job = authed_as("test@windmill.dev", Some(uuid::Uuid::new_v4()));
let not_job = authed_as("test@windmill.dev", None);
assert!(
windmill_api_auth::require_devops_role(&db, &job)
.await
.is_err(),
"a job token must not hold the devops role"
);
assert!(
windmill_api_auth::require_instance_admin(&job).is_err(),
"a job token must not hold instance admin"
);
// The identity is a real superadmin, so without the job provenance both gates pass —
// proving the rejections above key off `job_id` and not the fixture's user.
assert!(
windmill_api_auth::require_devops_role(&db, &not_job)
.await
.is_ok(),
"a superadmin API token must still hold the devops role"
);
assert!(
windmill_api_auth::require_instance_admin(&not_job).is_ok(),
"a superadmin API token must still hold instance admin"
);
// The boolean sibling, which `list_worker_groups` consults to decide whether to
// obfuscate rather than to refuse: reading `true` there returns `env_vars_static` in
// the clear, so this one fails by leaking rather than by letting a request through.
assert!(
!windmill_api_auth::is_instance_admin(&job),
"a job token must not read as instance admin"
);
assert!(
windmill_api_auth::is_instance_admin(&not_job),
"an admin API token must still read as instance admin"
);
// `forbid_superadmin_job_token` guards the same class of route but keys on two things
// at once, so all three combinations are worth pinning: it fires only for a job token
// whose identity is a superadmin.
assert!(
windmill_api_auth::forbid_superadmin_job_token(&db, &job.email, job.job_id)
.await
.is_err(),
"a superadmin job token must be forbidden"
);
assert!(
windmill_api_auth::forbid_superadmin_job_token(&db, &not_job.email, None)
.await
.is_ok(),
"a superadmin API token carries no job provenance to forbid"
);
assert!(
windmill_api_auth::forbid_superadmin_job_token(
&db,
"test2@windmill.dev",
Some(uuid::Uuid::new_v4())
)
.await
.is_ok(),
"a job token running as a non-superadmin is not what this gate withholds"
);
// `forbid_elevated_job_token` is the same shape one tier wider — `is_devops_email` is
// true for superadmins too. The embed-token case above reaches it, but only ever with
// an elevated identity; these pin the other two combinations, so collapsing the gate
// into a blanket job-token refusal would be caught here rather than by whoever next
// creates a token from a script.
assert!(
windmill_api_auth::forbid_elevated_job_token(&db, "devops@windmill.dev", job.job_id)
.await
.is_err(),
"a devops job token must not mint a credential"
);
assert!(
windmill_api_auth::forbid_elevated_job_token(&db, &job.email, job.job_id)
.await
.is_err(),
"a superadmin job token must not mint a credential"
);
assert!(
windmill_api_auth::forbid_elevated_job_token(
&db,
"test2@windmill.dev",
Some(uuid::Uuid::new_v4())
)
.await
.is_ok(),
"an unelevated job token is left to the confinement check, not refused here"
);
Ok(())
}