fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr) (#10124)

* fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr)

Privilege escalation: an app/flow/schedule/trigger execution policy's `on_behalf_of`
(which a `wm_deployers` member can set) could point at a superadmin email. The
resulting job `WM_TOKEN` then passed the email-based superadmin checks, granting
instance superadmin. `forbid_superadmin_job_token` only guarded ~15 of ~75 routes.

Fix at the token layer: a WM_TOKEN must never satisfy a superadmin gate,
regardless of whose email it runs as (sentinel OR a real superadmin).

- `ApiAuthed` gains a `job_id` field, stamped once in `AuthCache::get_opt_job_authed`
  from the resolved token's job_id (correct even on cache hits).
- `require_super_admin(db, email)` -> `require_super_admin(db, &ApiAuthed)`, rejects
  `authed.job_id.is_some()`. `require_super_admin_email` kept for the few internal
  callers without an ApiAuthed.
- `is_super_admin_authed(db, &ApiAuthed)` for the boolean `is_super_admin_email`
  authorization branches on request handlers (workspace deletion, fork drops,
  dev-workspace attach/archive, object-storage SSRF exemption, custom dbname, EE GHES
  + connected repositories, ...). Migrate ~75 sites (OSS + EE).
- CUSTOM_INSTANCE_DB reads the *authenticated* job_id, not the caller-supplied
  `?job_id` query param. Worker-tag check takes a precomputed job-aware `is_super_admin`
  on the request path.

Execution-time on-behalf checks (scheduled/flow worker-tag, Cloud enqueue quota,
is_devops_email) are hardened in a follow-up — see
docs/followup-onbehalf-execution-privilege-hardening.md.

Regression tests: a superadmin-email WM_TOKEN is rejected on `require_super_admin`
routes, on `DELETE /workspaces/delete/{w}` (403, workspace preserved), and on the
CUSTOM_INSTANCE_DB lookup with no `?job_id` (401); real superadmin tokens still succeed.

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

* fix: cap devops role at workspace admin and reject reserved on_behalf_of identities

Extends the job-token cap with three pieces:

- `require_devops_role` takes `&ApiAuthed` and rejects job tokens.
  `is_devops_email` is true for superadmin emails, so every worker-management,
  instance-config and service-log route was reachable by the same superadmin
  `WM_TOKEN` that `require_super_admin` already rejects.
- A `job_id` claim that does not parse as a uuid rejects the token rather than
  resolving to `None`, which would clear the job provenance and uncap it. Applies
  to the internal JWT and the external `jwt_ext_` path.
- Defense in depth at store time: `validate_on_behalf_of` refuses the reserved
  internal sentinels as an `on_behalf_of` on apps/flows/scripts/schedules/triggers,
  and app execution refuses a policy carrying one — covering already-persisted and
  forked-app rows that predate the cap. Deploying on behalf of a real user,
  including a real superadmin, stays allowed; the cap handles that at execution.

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

* fix(mcp): preserve job-token provenance when minting the proxy JWT

The MCP endpoint-tool proxy re-mints a JWT from the caller's ApiAuthed to
forward the proxied request, but passed job_id: None. A job's WM_TOKEN is
capped at workspace admin (GHSA-hfh4-cx4h-3fcr); dropping the job_id here
re-minted an uncapped token that satisfies require_super_admin /
require_devops_role on the proxied route (e.g. listWorkers exposing worker
IPs, job/workspace IDs, and sensitive tags).

Carry api_authed.job_id into create_jwt_token. Adds an in-module regression
that decodes the forwarded JWT and asserts the job_id is preserved for a job
caller and absent for a non-job caller.

Reported by Codex CI review (P1) on #10124.

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

* fix: cap the admin-or-devops gate at workspace admin for job tokens

require_admin_or_devops (the EE critical-alerts endpoints) grants when the
caller is a workspace admin OR an instance devops. is_devops_email is true
for superadmins, so a WM_TOKEN running on-behalf of a superadmin who is not a
member of the target workspace could clear the devops branch and read/ack that
workspace's critical alerts (GHSA-hfh4-cx4h-3fcr). This gate takes a bare
email, not an ApiAuthed, so the token-layer cap could not see it.

Thread the caller's job-token provenance and reject the devops branch for job
tokens, matching require_devops_role. The workspace-admin branch stays allowed
— that is the cap ceiling. Adds an enterprise-gated regression proving the
bypass is closed and a real superadmin token still clears the gate.

Found while auditing the PR for bare-email gates the choke-point cap misses.

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

* fix: cap instance-global is_admin gates at workspace admin for job tokens

Three instance-global routes gate on the caller's own `is_admin` claim, which
`ApiAuthed.is_admin` carries into a WM_TOKEN (it is a workspace-admin claim,
true for superadmins too). A job token is capped at workspace admin
(GHSA-hfh4-cx4h-3fcr), so its is_admin claim must not authorize instance
actions on a route with no workspace binding:

- `unarchive_workspace` — unarchive an arbitrary workspace by id
- `prune_concurrency_group` — delete a global concurrency group
- `list_worker_groups` — return unobfuscated `env_vars_static` (may hold secrets)

Add job-token-aware `is_instance_admin` / `require_instance_admin` helpers (the
same shape as `require_super_admin` / `require_devops_role`) and use them at
these three sites. Workspace-scoped `require_admin(authed.is_admin, ...)` gates
are intentionally left unchanged — a workspace-admin job token is within the
cap there. Regression added covering all three; verified it lets a WM_TOKEN
unarchive/leak without the fix and is blocked with it.

Reported by Codex CI review (P1) on #10124.

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

* fix(mcp): drop orphaned path_field_renames from EndpointTool test helper

The merge with main adopted main's mcp path-substitution refactor (#10162),
which removed the `path_field_renames` field from `EndpointTool` and its
consumer (`substitute_path_params` no longer takes per-field path renames).
main's `runner.rs` `ep` test helper still constructed the struct with
`path_field_renames: None`, so the workspace test build (cargo test --all,
which compiles windmill-mcp's own #[cfg(test)] module under the `server`
feature) failed with E0560. A plain `cargo check` does not compile that test
module, so it only surfaced in CI's cargo_test.

Remove the orphaned field to match the struct.

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

* test: describe the sentinel-rejection policy the forged-identity test asserts

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

* fix: complete ApiAuthed initializers in feature-gated tests after merge

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

* fix: stop job tokens minting credentials that shed their provenance

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

* fix: cap the MCP OAuth approval mint at the same elevated-job-token gate

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

* fix: cap the self-service password reset at the elevated-job-token gate

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

* fix: cap app embed/SDK mints and scope widening at the elevated-job-token gate

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

* fix: keep job tokens from destroying the account they run on behalf of

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

* fix: deny job tokens a foreign-workspace admin claim and workspace ejection

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

* docs: keep the follow-up inventory in the PR instead of the repo

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

* fix: make the session workspace status gate job-token aware

session_workspace_status derived its superadmin branch from a bare email
check, so a job token carrying a superadmin identity resolved the existence
of workspaces it has no relationship with rather than seeing them as
deleted. Switch to is_super_admin_authed, matching every other instance
gate reached from a request ApiAuthed.

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

* revert: leave the global concurrency-group listing on the plain admin gate

The listing exposes concurrency keys across workspaces, which is metadata
rather than a capability, and it 401s rather than degrading. Keep the guard
on the prune route next to it, which is the destructive one.

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

* fix: keep the instance-admin gate on the global concurrency listing

The listing spans every workspace's concurrency keys, and the gate rejects
only job tokens: the !is_admin branch is the pre-existing check, so
workspaced tokens and interactive admins are unaffected.

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

* chore: update ee-repo-ref to d30af67d38954f9012f7bad08da23e347344b4c6

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

Previous ee-repo-ref: 7870573dbc3360f99bada143f094c67dce0d9e9c

New ee-repo-ref: d30af67d38954f9012f7bad08da23e347344b4c6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-08-19 22:33:46 +02:00
committed by GitHub
parent ed2ff6c5e7
commit c2deea13b7
41 changed files with 1535 additions and 208 deletions
+1 -1
View File
@@ -1 +1 @@
dff61d6da80d15f8327af99d322c00cc91f784ff
d30af67d38954f9012f7bad08da23e347344b4c6
+5
View File
@@ -36,6 +36,11 @@ INSERT INTO password(email, password_hash, login_type, super_admin, verified, na
VALUES ('test2@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Test User 2')
ON CONFLICT DO NOTHING;
-- Instance devops user (not a superadmin): the tier a token mint must not launder
INSERT INTO password(email, password_hash, login_type, super_admin, devops, verified, name)
VALUES ('devops@windmill.dev', 'not-a-real-hash', 'password', false, true, true, 'Devops User')
ON CONFLICT DO NOTHING;
-- Deployer user (non-admin but in wm_deployers group)
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('deployer@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Deployer User')
+1
View File
@@ -24,6 +24,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
+165
View File
@@ -2810,3 +2810,168 @@ async fn test_schedule_permissions_superadmin_not_in_workspace(
Ok(())
}
// ============================================================================
// Forged-superadmin on_behalf_of guard (GHSA-hfh4-cx4h-3fcr)
// ============================================================================
/// Reserved internal sentinel identities are rejected by name at deploy time on
/// every entity that stores a preserved on_behalf_of. Real identities stay
/// deployable by a `wm_deployers` member — including a real superadmin, and even
/// their email pinned onto an unrelated principal — because that escalation is
/// closed at execution by the job-token cap, not by restricting what is stored.
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
// Reserved internal sentinel (grants is_super_admin at execution by email).
const SENTINEL: &str = "superadmin_secret@windmill.dev";
// Reserved sentinel matched on permissioned_as.
const SYNC_SENTINEL: &str = "superadmin_sync@windmill.dev";
// Real instance superadmin, present only in `password` (not a workspace member).
const REAL_SA: &str = "superadmin-external@windmill.dev";
// App: deployer cannot pin the sentinel email.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
)
.json(&new_app_with_on_behalf_of(
"u/deployer-user/app_sentinel",
Some("u/original-user"),
Some(SENTINEL),
true,
))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"deployer must not pin the sentinel as an app on_behalf_of_email: {}",
resp.text().await?
);
// App: a real superadmin on_behalf_of is *allowed* at deploy (deployers may
// deploy on behalf of any real user). The escalation is closed at execution
// by the job-token cap, not by restricting what can be stored, so even a
// superadmin email pinned onto an unrelated principal deploys fine here.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
)
.json(&new_app_with_on_behalf_of(
"u/deployer-user/app_real_sa",
Some("u/original-user"),
Some(REAL_SA),
true,
))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"a real superadmin on_behalf_of is allowed at deploy (capped at execution): {}",
resp.text().await?
);
// App: a consistently named real superadmin identity is likewise allowed.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
)
.json(&new_app_with_on_behalf_of(
"u/deployer-user/app_consistent_sa",
Some("u/superadmin-external"),
Some(REAL_SA),
true,
))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"deployer may preserve a consistently named superadmin identity: {}",
resp.text().await?
);
// Flow: deployer cannot pin the sentinel email.
let resp = authed(
client().post(format!("{base}/flows/create")),
"DEPLOYER_TOKEN",
)
.json(&new_flow_with_on_behalf_of(
"u/deployer-user/flow_sentinel",
Some(SENTINEL),
true,
))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"deployer must not pin the sentinel as a flow on_behalf_of_email: {}",
resp.text().await?
);
// Script: deployer cannot pin the sentinel email.
let resp = authed(
client().post(format!("{base}/scripts/create")),
"DEPLOYER_TOKEN",
)
.json(&new_script_with_on_behalf_of(
"u/deployer-user/script_sentinel",
Some(SENTINEL),
true,
))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"deployer must not pin the sentinel as a script on_behalf_of_email: {}",
resp.text().await?
);
// Schedule: deployer cannot preserve the sync sentinel as permissioned_as.
let resp = authed(
client().post(format!("{base}/scripts/create")),
"DEPLOYER_TOKEN",
)
.json(&new_script_with_on_behalf_of(
"u/deployer-user/sched_guard_script",
None,
false,
))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
let resp = authed(
client().post(format!("{base}/schedules/create")),
"DEPLOYER_TOKEN",
)
.json(&json!({
"path": "u/deployer-user/schedule_sync_sentinel",
"schedule": "0 0 */6 * * *",
"timezone": "UTC",
"script_path": "u/deployer-user/sched_guard_script",
"is_flow": false,
"enabled": false,
"permissioned_as": SYNC_SENTINEL,
"preserve_permissioned_as": true
}))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"deployer must not preserve the sync sentinel as a schedule permissioned_as: {}",
resp.text().await?
);
Ok(())
}
@@ -177,6 +177,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
+651
View File
@@ -204,3 +204,654 @@ async fn test_wm_token_cannot_manage_superadmin_users(db: Pool<Postgres>) -> any
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`. `GET
/// /api/settings/list_global` is gated solely by `require_super_admin`, so it
/// exercises the token-layer guard (GHSA-hfh4-cx4h-3fcr).
#[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(),
401,
"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?
);
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(),
401,
"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(),
401,
"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(),
401,
"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(),
401,
"superadmin WM_TOKEN must not list global concurrency groups: {}",
resp.text().await?
);
// 4. Worker-group config: the static env value must be masked for a job token.
let body = authed(
client().get(format!("{base}/configs/list_worker_groups")),
&sa_wm,
)
.send()
.await?
.text()
.await?;
assert!(
!body.contains("supersecretvalue"),
"superadmin WM_TOKEN must get the obfuscated worker-group view: {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(),
401,
"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(),
401,
"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(),
401,
"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(),
401,
"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(),
401,
"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(),
401,
"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(),
401,
"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(())
}
+5 -5
View File
@@ -5524,25 +5524,25 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> a
"bare(test-workspace)".to_string(),
])));
// test2 is not a superadmin, who would bypass the scope check entirely.
let email = "test2@windmill.dev";
// A non-superadmin caller (a superadmin would bypass the scope check entirely).
let is_super_admin = false;
for (w_id, tag) in [("test-workspace", "bare"), ("test-workspace", "forky")] {
assert!(
check_tag_available_for_workspace_internal(&db, w_id, tag, email, None)
check_tag_available_for_workspace_internal(&db, w_id, tag, is_super_admin, None)
.await
.is_ok(),
"{tag} should be available in the workspace it names"
);
}
assert!(
check_tag_available_for_workspace_internal(&db, fork, "forky", email, None)
check_tag_available_for_workspace_internal(&db, fork, "forky", is_super_admin, None)
.await
.is_ok(),
"a `*` tag must be granted to a fork through its parent lineage"
);
assert!(
check_tag_available_for_workspace_internal(&db, fork, "bare", email, None)
check_tag_available_for_workspace_internal(&db, fork, "bare", is_super_admin, None)
.await
.is_err(),
"an unmarked tag must not reach a fork of the workspace it names"
@@ -32,6 +32,7 @@ fn outsider() -> ApiAuthed {
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
@@ -417,9 +418,16 @@ async fn an_editor_graph_renders_only_through_its_own_job(db: Pool<Postgres>) {
// Through the PATH — which is what the workspace graph and every run of the
// deployed version ask for — a buffer parse must not appear at all. It
// describes an editor's unsaved state, not what the script owns.
let workspace = asset_graph_for(&admin, WS, UserDB::new(db.clone()), db.clone(), query(), None)
.await
.unwrap();
let workspace = asset_graph_for(
&admin,
WS,
UserDB::new(db.clone()),
db.clone(),
query(),
None,
)
.await
.unwrap();
let workspace = serde_json::to_value(&workspace.0).unwrap().to_string();
assert!(
!workspace.contains("u/a/wh/analytics/draft"),
+35 -1
View File
@@ -137,6 +137,20 @@ impl AuthCache {
&self,
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
let mut opt_job_authed = self.get_opt_job_authed_inner(w_id, token).await?;
// Single source of truth: mirror the resolved job_id onto the authed so
// every consumer (require_super_admin, ...) sees that this identity came
// from a job's WM_TOKEN, even on an AUTH_CACHE hit whose cached authed
// predates this field.
opt_job_authed.authed.job_id = opt_job_authed.job_id;
Some(opt_job_authed)
}
async fn get_opt_job_authed_inner(
&self,
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
// In no-auth mode there are no real tokens: resolve directly as the
// admin superadmin so direct cache callers (e.g. get_all_runnables,
@@ -218,8 +232,21 @@ impl AuthCache {
is_session_token,
token_prefix: claims.audit_span,
read_only: false,
job_id: None,
};
// Fail closed: a `job_id` claim that does not parse must reject
// the token rather than resolve to `None`, which would clear the
// job provenance and uncap the token (GHSA-hfh4-cx4h-3fcr).
let job_id = match claims.job_id {
Some(j) => match uuid::Uuid::from_str(&j) {
Ok(job_id) => Some(job_id),
Err(_) => {
tracing::error!("JWT auth error: job_id claim is not a uuid");
return None;
}
},
None => None,
};
let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok());
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
@@ -319,6 +346,7 @@ impl AuthCache {
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
} else {
tracing::warn!(
@@ -371,6 +399,7 @@ impl AuthCache {
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
} else {
tracing::warn!(
@@ -446,6 +475,7 @@ impl AuthCache {
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
}
None if super_admin => {
@@ -469,6 +499,7 @@ impl AuthCache {
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
}),
Err(e) => {
tracing::error!(
@@ -494,6 +525,7 @@ impl AuthCache {
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
}
}
@@ -531,6 +563,7 @@ impl AuthCache {
is_session_token: false,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
job_id: None,
};
Some(OptJobAuthed { authed, job_id: None })
} else {
@@ -740,6 +773,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
+136 -5
View File
@@ -73,6 +73,11 @@ pub struct ApiAuthed {
pub is_session_token: bool,
pub token_prefix: Option<String>,
pub read_only: bool,
/// Set when this authed was resolved from a job's `WM_TOKEN`. Such a token's
/// identity is derived from an app/flow `on_behalf_of` that a `wm_deployers`
/// member can point at a superadmin, so it must never be trusted as a global
/// superadmin (`require_super_admin`), GHSA-hfh4-cx4h-3fcr.
pub job_id: Option<uuid::Uuid>,
}
impl ApiAuthed {
@@ -159,6 +164,7 @@ impl From<Authed> for ApiAuthed {
is_session_token: false,
token_prefix: value.token_prefix,
read_only: false,
job_id: None,
}
}
}
@@ -247,7 +253,10 @@ impl windmill_mcp::server::McpAuth for ApiAuthed {
// ------------ Utility functions ------------
pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
/// Assert the *email* belongs to a superadmin. Prefer [`require_super_admin`],
/// which also rejects job tokens (`WM_TOKEN`); use this only where no `ApiAuthed`
/// is available and the caller has separately guaranteed it is not a job token.
pub async fn require_super_admin_email(db: &DB, email: &str) -> error::Result<()> {
let is_admin = is_super_admin_email(db, email).await?;
if !is_admin {
@@ -259,6 +268,66 @@ pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
}
}
/// Assert the caller is a superadmin acting under their own credentials.
///
/// A job's `WM_TOKEN` runs as the runnable's `on_behalf_of` identity, which a
/// non-superadmin `wm_deployers` member can point at a superadmin — so a job
/// token must never satisfy a global superadmin gate regardless of whose email
/// it carries (GHSA-hfh4-cx4h-3fcr). A real superadmin needing this from a script
/// uses a dedicated superadmin token instead of `$WM_TOKEN`.
pub async fn require_super_admin(db: &DB, authed: &ApiAuthed) -> error::Result<()> {
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN). If a script \
genuinely needs to do this, create a dedicated superadmin token from the User \
settings drawer (the 'Tokens' section), store it as a secret, and use that token \
explicitly instead of $WM_TOKEN."
.to_owned(),
));
}
require_super_admin_email(db, &authed.email).await
}
/// Job-token-aware superadmin predicate for the many boolean `is_super_admin_email`
/// authorization branches (workspace deletion, fork drops, SSRF exemptions, ...).
/// A job's `WM_TOKEN` is never a superadmin regardless of whose email it carries
/// (GHSA-hfh4-cx4h-3fcr), so callers naturally fall through to the restricted path.
pub async fn is_super_admin_authed(db: &DB, authed: &ApiAuthed) -> error::Result<bool> {
if authed.job_id.is_some() {
return Ok(false);
}
is_super_admin_email(db, &authed.email).await
}
/// Instance-global admin predicate, job-token-aware. `ApiAuthed::is_admin` is a
/// *workspace*-admin claim (also true for superadmins), and a `WM_TOKEN` is capped
/// at workspace admin (GHSA-hfh4-cx4h-3fcr). Routes with no workspace binding that
/// treat `is_admin` as instance authorization (worker-group config, arbitrary
/// workspace unarchive, global concurrency pruning) must use this instead of the
/// raw `authed.is_admin`, so a job token can't wield a workspace-admin claim as an
/// instance action. Interactive admins are unaffected.
pub fn is_instance_admin(authed: &ApiAuthed) -> bool {
authed.is_admin && authed.job_id.is_none()
}
/// Hard-gate variant of [`is_instance_admin`] for instance-global routes: rejects
/// a job token (`WM_TOKEN`) explicitly, then requires admin.
pub fn require_instance_admin(authed: &ApiAuthed) -> error::Result<()> {
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN): it is an \
instance-global admin action and a job token is capped at workspace admin. \
If a script genuinely needs this, create a dedicated token from the User \
settings drawer and use it explicitly instead of $WM_TOKEN."
.to_owned(),
));
}
if !authed.is_admin {
return Err(Error::RequireAdmin(authed.username.clone()));
}
Ok(())
}
/// Forbid sensitive global user/token management when authenticated as a
/// superadmin *via a job token* (`WM_TOKEN`).
///
@@ -286,6 +355,53 @@ pub async fn forbid_superadmin_job_token(
Ok(())
}
/// Forbid *minting a durable credential* from a job token that carries an elevated
/// instance identity (superadmin or `devops`; [`is_devops_email`] covers both).
///
/// The gates that cap `$WM_TOKEN` key off `ApiAuthed::job_id`, which only a job
/// token carries. A token minted from one is an ordinary database-backed token with
/// no such provenance, so it passes every one of those gates by email alone — the
/// cap would last only until the script exchanged its token for a fresh one
/// (GHSA-hfh4-cx4h-3fcr). Narrower than rejecting all job tokens: a script running
/// as an unprivileged identity has nothing to launder and still mints freely.
pub async fn forbid_elevated_job_token(
db: &DB,
email: &str,
job_id: Option<uuid::Uuid>,
) -> error::Result<()> {
if job_id.is_some() && is_devops_email(db, email).await? {
return Err(Error::NotAuthorized(
"A job token ($WM_TOKEN) running as a superadmin or devops user cannot mint a new \
token, which would carry that identity without the job provenance that caps it. \
If a script genuinely needs this, create a dedicated token from the User settings \
drawer (the 'Tokens' section), store it as a secret, and use that token explicitly \
instead of $WM_TOKEN."
.to_owned(),
));
}
Ok(())
}
/// Forbid an irreversible action against the *account* a job token runs as.
///
/// A job token borrows an `on_behalf_of` identity to do the runnable's work, and a
/// `wm_deployers` member may point that at any real user. Destroying the account or
/// its credentials is never that work, and unlike the privilege gates the damage
/// does not depend on the identity being elevated — so this rejects every job
/// token, not just superadmin/devops ones (GHSA-hfh4-cx4h-3fcr).
pub fn forbid_job_token_account_destruction(authed: &ApiAuthed) -> error::Result<()> {
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN): it would destroy the \
account or credentials of the identity the job runs as. If this is genuinely \
intended, do it from the User settings drawer, or with a dedicated token created \
there and used explicitly instead of $WM_TOKEN."
.to_owned(),
));
}
Ok(())
}
pub fn check_scopes<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
where
F: FnOnce() -> String,
@@ -651,10 +767,24 @@ pub fn build_scope_path_filter(authed: &ApiAuthed, domain: &str, action: &str) -
ScopePathFilter::Restricted { exact, prefix }
}
pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> {
let is_devops = is_devops_email(db, email).await?;
if is_devops {
/// Assert the caller holds the instance-level `devops` role under their own
/// credentials.
///
/// `devops` is instance-level and [`is_devops_email`] is also true for
/// superadmins, so this gate is reachable by the same job token that
/// [`require_super_admin`] rejects, and is capped the same way
/// (GHSA-hfh4-cx4h-3fcr).
pub async fn require_devops_role(db: &DB, authed: &ApiAuthed) -> error::Result<()> {
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN). If a script \
genuinely needs this, create a dedicated token from the User settings drawer \
(the 'Tokens' section), store it as a secret, and use that token explicitly \
instead of $WM_TOKEN."
.to_owned(),
));
}
if is_devops_email(db, &authed.email).await? {
Ok(())
} else {
Err(Error::NotAuthorized(
@@ -913,6 +1043,7 @@ pub async fn fetch_api_authed_from_permissioned_as(
is_session_token: false,
token_prefix: authed.token_prefix,
read_only: false,
job_id: None,
};
API_AUTHED_CACHE.insert(
+12 -9
View File
@@ -23,7 +23,7 @@ use windmill_common::{
DB,
};
use windmill_api_auth::{require_devops_role, ApiAuthed};
use windmill_api_auth::{is_instance_admin, require_devops_role, ApiAuthed};
pub fn global_service() -> Router {
Router::new()
@@ -75,7 +75,10 @@ async fn list_worker_groups(
}
}
}
let configs = if !authed.is_admin {
// Worker-group configs are instance-global and expose env_vars_static (may hold
// secrets); a job token (capped at workspace admin) gets the obfuscated view even
// when its identity is a superadmin. See is_instance_admin (GHSA-hfh4-cx4h-3fcr).
let configs = if !is_instance_admin(&authed) {
let mut obfuscated_configs: Vec<Config> = vec![];
for config in configs_raw {
let config_value_opt = config.config.as_object().map(|obj| obj.to_owned());
@@ -117,7 +120,7 @@ async fn get_config(
Path(name): Path<String>,
Extension(db): Extension<DB>,
) -> error::JsonResult<Option<serde_json::Value>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let config = sqlx::query_as!(Config, "SELECT name, config FROM config WHERE name = $1", name)
.fetch_optional(&db)
@@ -133,7 +136,7 @@ async fn update_config(
authed: ApiAuthed,
Json(config): Json<serde_json::Value>,
) -> error::Result<String> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
#[cfg(not(feature = "enterprise"))]
let config = if name.starts_with("worker__") {
@@ -212,7 +215,7 @@ async fn delete_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let mut tx = db.begin().await?;
@@ -280,7 +283,7 @@ async fn native_kubernetes_autoscaling_healthcheck(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> Result<(), windmill_autoscaling::kubernetes_integration_ee::KubeError> {
require_devops_role(&db, &authed.email).await.map_err(|e| {
require_devops_role(&db, &authed).await.map_err(|e| {
windmill_autoscaling::kubernetes_integration_ee::KubeError::Other(e.to_string())
})?;
@@ -317,7 +320,7 @@ async fn list_configs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<Config>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let configs = sqlx::query_as!(Config, "SELECT name, config FROM config")
.fetch_all(&db)
.await?;
@@ -342,7 +345,7 @@ async fn list_all_workspace_dependencies(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<WorkspaceDependencySummary>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let deps = sqlx::query!(
r#"SELECT workspace_id, name, language AS "language: windmill_common::scripts::ScriptLang"
FROM workspace_dependencies
@@ -374,7 +377,7 @@ async fn list_all_dedicated_with_deps(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> error::JsonResult<Vec<DedicatedScriptDepsWithWorkspace>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let rows = sqlx::query!(
r#"SELECT DISTINCT ON (workspace_id, path)
+19 -22
View File
@@ -612,8 +612,7 @@ async fn create_flow(
// Apply folder default_permissioned_as on create when the caller did not
// explicitly preserve a value and the user can preserve.
let explicit_preserve = (nf.on_behalf_of_email.is_some()
|| nf.on_behalf_of.is_some())
let explicit_preserve = (nf.on_behalf_of_email.is_some() || nf.on_behalf_of.is_some())
&& nf.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed);
if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) {
@@ -633,16 +632,15 @@ async fn create_flow(
check_schedule_conflict(&mut tx, &w_id, &nf.path).await?;
let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok());
let resolved_on_behalf_of =
windmill_common::resolve_on_behalf_of(
nf.on_behalf_of_email.as_deref(),
nf.on_behalf_of.as_deref(),
nf.preserve_on_behalf_of.unwrap_or(false),
&authed,
&w_id,
&db,
)
.await?;
let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of(
nf.on_behalf_of_email.as_deref(),
nf.on_behalf_of.as_deref(),
nf.preserve_on_behalf_of.unwrap_or(false),
&authed,
&w_id,
&db,
)
.await?;
// Written beside the principal only while a worker that still reads it may be live.
let legacy_on_behalf_of_email =
windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db)
@@ -1160,16 +1158,15 @@ async fn update_flow(
let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?;
let is_new_path = nf.path != flow_path;
let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok());
let resolved_on_behalf_of =
windmill_common::resolve_on_behalf_of(
nf.on_behalf_of_email.as_deref(),
nf.on_behalf_of.as_deref(),
nf.preserve_on_behalf_of.unwrap_or(false),
&authed,
&w_id,
&db,
)
.await?;
let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of(
nf.on_behalf_of_email.as_deref(),
nf.on_behalf_of.as_deref(),
nf.preserve_on_behalf_of.unwrap_or(false),
&authed,
&w_id,
&db,
)
.await?;
// Written beside the principal only while a worker that still reads it may be live.
let legacy_on_behalf_of_email =
windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db)
+7 -7
View File
@@ -299,7 +299,7 @@ async fn create_igroup(
) -> Result<String> {
use uuid::Uuid;
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx = db.begin().await?;
let normalized_name = convert_name(&ng.name);
@@ -464,7 +464,7 @@ async fn update_igroup(
Path(name): Path<String>,
Json(igroup_update): Json<IGroupUpdate>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
let exists_opt = sqlx::query("SELECT 1 FROM instance_group WHERE name = $1")
@@ -656,7 +656,7 @@ async fn delete_igroup(
Extension(db): Extension<DB>,
Path(name): Path<String>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// FOR UPDATE: the group row is the group-level mutex, taken before the workspace
@@ -970,7 +970,7 @@ async fn add_user_igroup(
Path(name): Path<String>,
Json(Email { email }): Json<Email>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
@@ -1189,7 +1189,7 @@ async fn remove_user_igroup(
Path(name): Path<String>,
Json(Email { email }): Json<Email>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx = db.begin().await?;
// FOR UPDATE: the group row is the group-level mutex, taken before the workspace
@@ -1330,7 +1330,7 @@ async fn export_igroups(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<ExportedIGroup>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx = db.begin().await?;
let igroups = sqlx::query_as!(
ExportedIGroup,
@@ -1366,7 +1366,7 @@ async fn overwrite_igroups(
Extension(db): Extension<DB>,
Json(igroups): Json<Vec<ExportedIGroup>>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut tx = db.begin().await?;
// The import replaces the whole group catalog, so the whole-table lock is its
@@ -62,6 +62,7 @@ fn test_authed() -> ApiAuthed {
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
@@ -1,9 +1,8 @@
use windmill_api_auth::{check_scopes, ApiAuthed};
use windmill_api_auth::{check_scopes, is_instance_admin, require_instance_admin, ApiAuthed};
use windmill_common::{
db::{UserDB, DB},
error::Error::PermissionDenied,
error::{self, JsonResult},
utils::require_admin,
};
use crate::query::{filter_list_completed_query, filter_list_queue_query};
@@ -44,7 +43,9 @@ async fn list_concurrency_groups(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<ConcurrencyGroups>> {
require_admin(authed.is_admin, &authed.username)?;
// Instance-global: the listing spans every workspace's concurrency keys, so a job
// token's workspace-admin claim must not reach it (mirrors the prune route below).
require_instance_admin(&authed)?;
let concurrency_counts = sqlx::query_as::<_, (String, i64)>(
"SELECT concurrency_id, (select COUNT(*) from jsonb_object_keys(job_uuids)) as n_job_uuids FROM concurrency_counter",
@@ -67,7 +68,9 @@ async fn prune_concurrency_group(
Extension(db): Extension<DB>,
Path(concurrency_key): Path<String>,
) -> JsonResult<()> {
if !authed.is_admin {
// Global concurrency-group pruning gated on the caller's own is_admin claim,
// so a job token (capped at workspace admin) must not pass.
if !is_instance_admin(&authed) {
return Err(PermissionDenied(
"Only administrators can delete concurrency groups".to_string(),
));
@@ -283,7 +286,8 @@ async fn get_concurrent_intervals(
// This second transaction uses the db, so it will fetch information
// potentially forbidden to the user. It must be obscured before
// returning it
let running_jobs_db: Vec<UnifiedJob> = if lq.success.is_none() && lq.resolved != Some(true) {
let running_jobs_db: Vec<UnifiedJob> = if lq.success.is_none() && lq.resolved != Some(true)
{
sqlx::query_as(&sql_q).fetch_all(&db).await?
} else {
vec![]
+3 -1
View File
@@ -56,7 +56,9 @@ pub async fn check_tag_available_for_workspace(
) -> error::Result<()> {
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
let tags = get_scope_tags(authed);
check_tag_available_for_workspace_internal(db, w_id, tag, &authed.email, tags).await
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
check_tag_available_for_workspace_internal(db, w_id, tag, is_super_admin, tags).await
} else {
Ok(())
}
+15 -1
View File
@@ -339,6 +339,13 @@ async fn create_schedule(
)
.await?;
// Reject a forged superadmin run identity in a preserved permissioned_as
// (the sentinel guard; the email is derived from it so it always belongs).
windmill_common::auth::validate_on_behalf_of(
Some(&resolved_permissioned_as),
Some(&resolved_email),
)?;
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
check_path_conflict(&mut tx, &w_id, &ns.path).await?;
@@ -571,6 +578,13 @@ async fn edit_schedule(
authed.email.clone()
};
// Reject a forged superadmin run identity in a preserved permissioned_as
// (the sentinel guard; the email is derived from it so it always belongs).
windmill_common::auth::validate_on_behalf_of(
Some(&resolved_permissioned_as),
Some(&resolved_email),
)?;
let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
let schedule = sqlx::query_as!(
@@ -1413,7 +1427,7 @@ async fn set_default_error_handler(
Path(w_id): Path<String>,
Json(payload): Json<ErrorOrRecoveryHandler>,
) -> Result<()> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let (key, value) = match payload.handler_type {
HandlerType::Error => {
let key = format!("default_error_handler_{}", w_id);
+43 -44
View File
@@ -50,7 +50,6 @@ use windmill_common::secret_backend::{
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
};
use windmill_common::{
auth::is_super_admin_email,
ee_oss::{get_license_plan, LicensePlan},
email_oss::{send_email_plain_text, SMTP_ENABLED},
error::{self, pg_error_message, JsonResult, Result},
@@ -236,7 +235,7 @@ pub async fn test_email(
authed: ApiAuthed,
Json(test_email): Json<TestEmail>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
if !SMTP_ENABLED {
return Err(error::Error::Generic(
axum::http::StatusCode::NOT_IMPLEMENTED,
@@ -290,7 +289,7 @@ pub async fn test_s3_bucket(
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
// object store usually lives on the local/private network and all authenticated users are
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
let is_super_admin = is_super_admin_email(&db, &authed.email).await?;
let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
let restrict = !is_super_admin && *CLOUD_HOSTED;
if restrict {
validate_object_storage_test(&test_s3_bucket).await?;
@@ -590,7 +589,7 @@ async fn get_object_storage_usage(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<storage_usage::StorageUsageProgress>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
Ok(Json(storage_usage::get_status(&db).await?))
}
@@ -599,7 +598,7 @@ async fn compute_object_storage_usage(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<axum::http::StatusCode> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
storage_usage::try_start(&db).await?;
storage_usage::spawn_compute(db.clone());
Ok(axum::http::StatusCode::ACCEPTED)
@@ -610,7 +609,7 @@ async fn run_log_cleanup(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<axum::http::StatusCode> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
log_cleanup::try_start(&db).await?;
log_cleanup::spawn_cleanup(db.clone());
Ok(axum::http::StatusCode::ACCEPTED)
@@ -621,7 +620,7 @@ async fn log_cleanup_status(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<log_cleanup::LogCleanupProgress>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
Ok(Json(log_cleanup::get_status(&db).await?))
}
@@ -630,7 +629,7 @@ async fn audit_logs_s3_status(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<audit_logs_s3::AuditLogsS3ExportStatus>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
Ok(Json(audit_logs_s3::get_status(&db).await?))
}
@@ -640,7 +639,7 @@ async fn run_audit_logs_s3_backfill(
authed: ApiAuthed,
Json(req): Json<audit_logs_s3_backfill::BackfillRequest>,
) -> error::Result<axum::http::StatusCode> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
return Err(error::Error::BadRequest(
"Audit log export to object storage is an Enterprise feature".to_string(),
@@ -656,7 +655,7 @@ async fn audit_logs_s3_backfill_status(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<audit_logs_s3_backfill::AuditBackfillProgress>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
Ok(Json(audit_logs_s3_backfill::get_status(&db).await?))
}
@@ -670,7 +669,7 @@ pub async fn test_license_key(
authed: ApiAuthed,
Json(TestKey { license_key }): Json<TestKey>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?;
if expired {
@@ -691,7 +690,7 @@ pub async fn get_offline_license_status(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<Option<windmill_common::ee_oss::OfflineCapStatus>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone();
let is_offline = matches!(&offline, Some(m) if m.is_offline());
@@ -717,7 +716,7 @@ pub async fn get_instance_hash(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<InstanceHash> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
#[cfg(feature = "enterprise")]
let hash = windmill_common::ee_oss::compute_instance_hash(&db)
.await
@@ -731,7 +730,7 @@ pub async fn get_local_settings(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<serde_json::Value> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let mut settings = serde_json::Map::new();
for key in ENV_SETTINGS.iter() {
@@ -790,7 +789,7 @@ pub async fn set_global_setting(
Path(key): Path<String>,
Json(value): Json<Value>,
) -> error::Result<()> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
set_global_setting_internal(&db, key, value.value.unwrap_or(serde_json::Value::Null)).await
}
@@ -1149,7 +1148,7 @@ async fn get_instance_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<InstanceConfig> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let config = InstanceConfig::from_db(&db)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
@@ -1160,7 +1159,7 @@ async fn get_instance_config_yaml(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<Response> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let config = InstanceConfig::from_db(&db)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
@@ -1178,7 +1177,7 @@ async fn set_instance_config(
authed: ApiAuthed,
Json(desired): Json<InstanceConfig>,
) -> error::Result<()> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let current = InstanceConfig::from_db(&db)
.await
@@ -1277,7 +1276,7 @@ pub async fn get_global_setting(
&& key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
&& key != WS_BASE_URL_SETTING
{
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
}
let value = sqlx::query!("SELECT value FROM global_settings WHERE name = $1", key)
.fetch_optional(&db)
@@ -1301,7 +1300,7 @@ async fn github_app_stale_webhooks(
Extension(_db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<serde_json::Value> {
require_super_admin(&_db, &authed.email).await?;
require_super_admin(&_db, &authed).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
{
let stale = windmill_common::git_sync_ee::stale_webhook_repos(&_db).await?;
@@ -1318,7 +1317,7 @@ async fn list_global_settings(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<Vec<GlobalSetting>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let settings = sqlx::query_as!(GlobalSetting, "SELECT name, value FROM global_settings")
.fetch_all(&db)
.await?;
@@ -1334,7 +1333,7 @@ async fn list_global_settings() -> JsonResult<String> {
}
pub async fn send_stats(Extension(db): Extension<DB>, authed: ApiAuthed) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_common::stats_oss::send_stats(
&HTTP_CLIENT,
&db,
@@ -1351,7 +1350,7 @@ async fn restart_worker_group(
authed: ApiAuthed,
Path(worker_group): Path<String>,
) -> error::Result<String> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
sqlx::query!(
"INSERT INTO notify_event (channel, payload) VALUES ('restart_worker_group', $1)",
@@ -1376,7 +1375,7 @@ pub async fn get_stats(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<StatsDownload> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let stats = windmill_common::stats_oss::get_stats_payload(
&db,
&windmill_common::stats_oss::SendStatsReason::Manual,
@@ -1406,7 +1405,7 @@ pub async fn get_latest_key_renewal_attempt(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> JsonResult<Option<KeyRenewalAttempt>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let last_attempt = sqlx::query!(
"SELECT value, created_at FROM metrics WHERE id = $1 ORDER BY created_at DESC LIMIT 1",
@@ -1449,7 +1448,7 @@ pub async fn renew_license_key(
Query(LicenseQuery { license_key }): Query<LicenseQuery>,
authed: ApiAuthed,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let result = windmill_common::ee_oss::renew_license_key(
&HTTP_CLIENT,
&db,
@@ -1495,7 +1494,7 @@ pub async fn test_critical_channels(
authed: ApiAuthed,
Json(test_critical_channels): Json<Vec<CriticalErrorChannel>>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
#[cfg(feature = "enterprise")]
send_critical_alert(
@@ -1519,7 +1518,7 @@ pub async fn get_critical_alerts(
authed: ApiAuthed,
Query(params): Query<windmill_alerting::AlertQueryParams>,
) -> JsonResult<serde_json::Value> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
windmill_alerting::get_critical_alerts(db, params, None).await
}
@@ -1535,7 +1534,7 @@ pub async fn acknowledge_critical_alert(
authed: ApiAuthed,
Path(id): Path<i32>,
) -> error::Result<String> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
windmill_alerting::acknowledge_critical_alert(db, None, id).await
}
@@ -1549,7 +1548,7 @@ pub async fn acknowledge_all_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_alerting::acknowledge_all_critical_alerts(db, None).await
}
@@ -1607,7 +1606,7 @@ async fn list_custom_instance_pg_databases(
))
})?;
if is_super_admin_email(&db, &authed.email).await? {
if windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
// Enrich each database with the list of workspaces referencing it through
// either a ducklake catalog or a datatable database whose resource_type is
// 'instance'. Not stored in DB to avoid drift.
@@ -1657,7 +1656,7 @@ async fn refresh_custom_instance_user_pwd(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<()> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_common::utils::refresh_custom_instance_user_pwd(&db).await?;
windmill_common::utils::refresh_custom_instance_replication_user_pwd(&db).await?;
Ok(Json(()))
@@ -1696,7 +1695,7 @@ async fn setup_custom_instance_pg_database_inner(
dbname: &str,
logs: &mut CustomInstanceDbLogs,
) -> Result<()> {
require_super_admin(db, &authed.email).await?;
require_super_admin(db, &authed).await?;
logs.super_admin = "OK".to_string();
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
logs.database_credentials = "OK".to_string();
@@ -1812,7 +1811,7 @@ async fn drop_custom_instance_pg_database(
Extension(db): Extension<DB>,
Path(dbname): Path<String>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_common::drop_custom_instance_database(&db, &dbname).await?;
@@ -1835,7 +1834,7 @@ pub async fn test_secret_backend(
authed: ApiAuthed,
Json(settings): Json<VaultSettings>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_common::secret_backend::test_vault_connection(&settings, Some(&db)).await?;
@@ -1855,7 +1854,7 @@ pub async fn migrate_secrets_to_vault(
authed: ApiAuthed,
Json(settings): Json<VaultSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let report = windmill_common::secret_backend::migrate_secrets_to_vault(&db, &settings).await?;
@@ -1875,7 +1874,7 @@ pub async fn migrate_secrets_to_database(
authed: ApiAuthed,
Json(settings): Json<VaultSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let report =
windmill_common::secret_backend::migrate_secrets_to_database(&db, &settings).await?;
@@ -1892,7 +1891,7 @@ pub async fn test_azure_kv_backend(
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_common::secret_backend::test_azure_kv_connection(&settings).await?;
@@ -1908,7 +1907,7 @@ pub async fn migrate_secrets_to_azure_kv(
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let report =
windmill_common::secret_backend::migrate_secrets_to_azure_kv(&db, &settings).await?;
@@ -1925,7 +1924,7 @@ pub async fn migrate_secrets_from_azure_kv(
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let report =
windmill_common::secret_backend::migrate_secrets_from_azure_kv(&db, &settings).await?;
@@ -1940,7 +1939,7 @@ pub async fn test_aws_sm_backend(
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
windmill_common::secret_backend::test_aws_sm_connection(&settings).await?;
Ok("Successfully connected to AWS Secrets Manager".to_string())
}
@@ -1952,7 +1951,7 @@ pub async fn migrate_secrets_to_aws_sm(
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let report = windmill_common::secret_backend::migrate_secrets_to_aws_sm(&db, &settings).await?;
Ok(Json(report))
}
@@ -1964,7 +1963,7 @@ pub async fn migrate_secrets_from_aws_sm(
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let report =
windmill_common::secret_backend::migrate_secrets_from_aws_sm(&db, &settings).await?;
Ok(Json(report))
@@ -2063,7 +2062,7 @@ async fn sync_cached_resource_types(
authed: ApiAuthed,
Query(SyncResourceTypesQuery { name }): Query<SyncResourceTypesQuery>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
use windmill_common::worker::HUB_RT_CACHE_DIR;
let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR);
+40 -19
View File
@@ -27,7 +27,10 @@ use axum::{
Json, Router,
};
use hyper::{header::LOCATION, StatusCode};
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin, OptJobAuthed};
use windmill_api_auth::{
forbid_elevated_job_token, forbid_job_token_account_destruction, forbid_superadmin_job_token,
require_super_admin, OptJobAuthed,
};
use windmill_common::usernames::{
generate_instance_wide_unique_username, get_instance_username_or_create_pending,
};
@@ -427,7 +430,7 @@ async fn list_addable_instance_users(
Path(w_id): Path<String>,
Query(AddableInstanceUsersQuery { search, per_page }): Query<AddableInstanceUsersQuery>,
) -> JsonResult<Vec<AddableInstanceUser>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let per_page = per_page.unwrap_or(10).clamp(1, 100);
// An absent search yields '%%', which matches every row.
let search = format!(
@@ -500,7 +503,7 @@ async fn list_users_as_super_admin(
Query(pagination): Query<Pagination>,
Query(ActiveUsersOnly { active_only }): Query<ActiveUsersOnly>,
) -> JsonResult<Vec<GlobalUserInfo>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let per_page = pagination.per_page.unwrap_or(10000).max(1);
let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page;
@@ -1231,6 +1234,7 @@ async fn join_workspace<'c>(
}
async fn leave_instance(Extension(db): Extension<DB>, authed: ApiAuthed) -> Result<String> {
forbid_job_token_account_destruction(&authed)?;
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email)
.execute(&mut *tx)
@@ -1471,7 +1475,7 @@ async fn update_user(
Extension(db): Extension<DB>,
Json(eu): Json<EditUser>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
@@ -1647,7 +1651,7 @@ async fn delete_user(
Path(email_to_delete): Path<String>,
Extension(db): Extension<DB>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
@@ -1728,7 +1732,7 @@ async fn change_user_email(
Extension(db): Extension<DB>,
Json(ce): Json<ChangeUserEmail>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
// The target is matched verbatim (accounts predating email normalization can hold uppercase),
@@ -2297,12 +2301,10 @@ async fn change_user_email(
// Read back inside the transaction: the address is derived at dispatch through a cache
// that nothing else evicts, so without this a job pushed in the next 60s would resolve
// the old address and with it the wrong superadmin flag and instance groups.
let memberships = sqlx::query_scalar!(
"SELECT workspace_id FROM usr WHERE email = $1",
&new_email
)
.fetch_all(&mut *tx)
.await?;
let memberships =
sqlx::query_scalar!("SELECT workspace_id FROM usr WHERE email = $1", &new_email)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
@@ -2604,7 +2606,7 @@ async fn set_login_type(
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(et): Json<EditLoginType>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
@@ -2747,6 +2749,18 @@ async fn refresh_token(
authed: ApiAuthed,
cookies: Cookies,
) -> Result<String> {
// The session token minted below is database-backed and carries no job provenance,
// so a job token that exchanged itself for one would shed the `job_id` every
// `$WM_TOKEN` cap keys off (GHSA-hfh4-cx4h-3fcr). Only a browser session refreshes.
if authed.job_id.is_some() {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN). If a script \
genuinely needs a token of its own, create a dedicated token from the User \
settings drawer (the 'Tokens' section), store it as a secret, and use that \
token explicitly instead of $WM_TOKEN."
.to_string(),
));
}
if let Some(thresh_s) = query.if_expiring_in_less_than_s {
let t_hash = windmill_common::auth::hash_token(&token);
let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s)
@@ -2890,7 +2904,7 @@ async fn create_token(
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(token_config): Json<NewToken>,
) -> Result<(StatusCode, String)> {
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
forbid_elevated_job_token(&db, &authed.email, job_id).await?;
check_token_create_rate_limit(&authed.username)?;
// `username_override_from_label` trusts a server-minted label to name the entity acting,
@@ -2934,7 +2948,7 @@ async fn impersonate(
} else {
Some(&token)
};
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
if new_token.impersonate_email.is_none() {
@@ -3089,6 +3103,7 @@ async fn delete_token(
authed: ApiAuthed,
Path(token_prefix): Path<String>,
) -> Result<String> {
forbid_job_token_account_destruction(&authed)?;
let mut tx = db.begin().await?;
let tokens_deleted: Vec<String> = sqlx::query_scalar(
@@ -3133,6 +3148,11 @@ async fn update_token_scopes(
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenScopesRequest>,
) -> Result<String> {
// Widening is what makes a narrowly-scoped mint (app embed, raw-app SDK, MCP
// OAuth) recoverable as a general credential: a job token is unscoped, so the
// caller check below would let it clear the scopes of any token sharing its
// email (GHSA-hfh4-cx4h-3fcr).
forbid_elevated_job_token(&db, &authed.email, authed.job_id).await?;
windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?;
let mut tx = db.begin().await?;
@@ -3261,6 +3281,7 @@ async fn leave_workspace(
Path(w_id): Path<String>,
authed: ApiAuthed,
) -> Result<String> {
forbid_job_token_account_destruction(&authed)?;
let mut tx = db.begin().await?;
sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
@@ -3402,11 +3423,11 @@ struct WorkspaceUsernameInfo {
username: String,
}
async fn get_instance_username_info(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Path(user_email): Path<String>,
Extension(db): Extension<DB>,
) -> JsonResult<InstanceUsernameInfo> {
require_super_admin(&db, &email).await?;
require_super_admin(&db, &authed).await?;
let mut tx = db.begin().await?;
let instance_username = match sqlx::query_scalar!(
"SELECT username FROM password WHERE email = $1",
@@ -3476,7 +3497,7 @@ async fn export_global_users(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
) -> JsonResult<Vec<ExportedGlobalUser>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
let users = sqlx::query_as!(
@@ -3516,7 +3537,7 @@ async fn overwrite_global_users(
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(users): Json<Vec<ExportedGlobalUser>>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM password")
+7 -7
View File
@@ -103,7 +103,7 @@ async fn list_worker_pings(
Extension(user_db): Extension<UserDB>,
Query(query): Query<ListWorkerQuery>,
) -> JsonResult<Vec<WorkerPing>> {
let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok();
let has_devops_role = require_devops_role(&db, &authed).await.is_ok();
if *HIDE_WORKERS_FOR_NON_ADMINS && !has_devops_role {
return Ok(Json(vec![]));
}
@@ -159,7 +159,7 @@ async fn exists_workers_with_tags(
// When TAGS_ARE_SENSITIVE is enabled, filter tags based on workspace visibility
if *TAGS_ARE_SENSITIVE {
let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok();
let has_devops_role = require_devops_role(&db, &authed).await.is_ok();
if !has_devops_role {
if let Some(ref workspace) = tags_query.workspace {
// This route is global, so the workspace is an unauthorized query param: check
@@ -229,7 +229,7 @@ async fn get_custom_tags(
return Ok(Json(all_tags));
}
if *TAGS_ARE_SENSITIVE {
let has_devops_role = require_devops_role(&db, &authed.email).await.is_ok();
let has_devops_role = require_devops_role(&db, &authed).await.is_ok();
if !has_devops_role {
return Ok(Json(vec![]));
}
@@ -268,7 +268,7 @@ async fn get_queue_metrics(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<QueueMetric>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let queue_metrics = sqlx::query_as!(
QueueMetric,
@@ -293,7 +293,7 @@ async fn get_queue_counts(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<std::collections::HashMap<String, u32>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let queue_counts = windmill_common::queue::get_queue_counts(&db).await;
Ok(Json(queue_counts))
}
@@ -302,7 +302,7 @@ async fn get_queue_running_counts(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<std::collections::HashMap<String, u32>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
let queue_running_counts = windmill_common::queue::get_queue_running_counts(&db).await;
Ok(Json(queue_running_counts))
}
@@ -327,7 +327,7 @@ async fn get_workspace_fairness_events(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<WorkspaceFairnessEvent>> {
require_devops_role(&db, &authed.email).await?;
require_devops_role(&db, &authed).await?;
// No cloud-host gate — workspace fairness is an Enterprise feature
// available on any multi-tenant EE deployment. Non-EE / non-enabled
@@ -777,7 +777,7 @@ async fn datatable_migrations_status(
/// Only workspace admins and super admins may opt a data table in or out of
/// migrations.
async fn require_datatable_migrations_manager(db: &DB, authed: &ApiAuthed) -> Result<()> {
if authed.is_admin || require_super_admin(db, &authed.email).await.is_ok() {
if authed.is_admin || require_super_admin(db, &authed).await.is_ok() {
Ok(())
} else {
Err(Error::BadRequest(
@@ -7,8 +7,8 @@
*/
use windmill_api_auth::{
build_scope_path_predicate, check_scopes, require_devops_role, require_is_writer,
require_super_admin, ApiAuthed,
build_scope_path_predicate, check_scopes, require_devops_role, require_instance_admin,
require_is_writer, require_super_admin, ApiAuthed,
};
use windmill_api_users::users::WorkspaceInvite;
use windmill_common::email_oss::send_email_if_possible;
@@ -2846,7 +2846,7 @@ async fn create_pg_database(
windmill_common::validate_dbname(&req.target_dbname)?;
// Non-superadmin: restrict dbname to wm_fork_ prefix
if !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? {
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
if !req.target_dbname.starts_with("wm_fork_") {
return Err(Error::BadRequest(
"Non-superadmin users can only create databases with names starting with 'wm_fork_'"
@@ -2963,7 +2963,7 @@ async fn import_pg_database(
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
if let Some(ref override_dbname) = req.target_dbname_override {
if !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? {
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
if !override_dbname.starts_with("wm_fork_") {
return Err(Error::BadRequest(
"Non-superadmin users can only override target dbname with names starting with 'wm_fork_'"
@@ -3037,7 +3037,7 @@ async fn edit_ducklake_config(
Json(new_config): Json<EditDucklakeConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let is_superadmin = require_super_admin(&db, &email).await.is_ok();
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
// Lake names end up interpolated in `ATTACH 'ducklake://<name>'`,
// generated maintenance SQL and the reserved maintenance schedule path
@@ -3130,11 +3130,11 @@ async fn edit_datatable_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, email, .. }: ApiAuthed,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(mut new_config): Json<EditDataTableConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let is_superadmin = require_super_admin(&db, &email).await.is_ok();
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
let mut tx = db.begin().await?;
@@ -4783,7 +4783,7 @@ async fn set_encryption_key(
Path(w_id): Path<String>,
Json(request): Json<SetEncryptionKeyRequest>,
) -> Result<()> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) {
return Err(Error::BadRequest(
@@ -4939,7 +4939,7 @@ async fn get_workspace_as_superadmin(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Workspace> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let workspace = sqlx::query_as!(
Workspace,
"SELECT
@@ -4970,9 +4970,8 @@ async fn list_workspaces_as_super_admin(
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Query(pagination): Query<Pagination>,
ApiAuthed { email, .. }: ApiAuthed,
) -> JsonResult<Vec<Workspace>> {
require_devops_role(&db, &email).await?;
require_devops_role(&db, &authed).await?;
let (per_page, offset) = paginate(pagination);
let mut tx = user_db.begin(&authed).await?;
@@ -5044,7 +5043,7 @@ struct SessionWorkspaceStatusRequest {
/// lingering, so it is deliberately not treated as unreachable.
async fn session_workspace_status(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Json(req): Json<SessionWorkspaceStatusRequest>,
) -> JsonResult<HashMap<String, String>> {
if req.workspace_ids.len() > 1000 {
@@ -5052,7 +5051,8 @@ async fn session_workspace_status(
"Too many workspace ids (max 1000)".to_string(),
));
}
let is_superadmin = windmill_common::auth::is_super_admin_email(&db, &email).await?;
let email = &authed.email;
let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
let rows = sqlx::query!(
// A missing workspace row must be caught before the membership arm: for a
// superadmin the two arms below both fall through, and a hard-deleted workspace
@@ -5254,7 +5254,7 @@ async fn create_workspace(
Json(nw): Json<CreateWorkspace>,
) -> Result<String> {
if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
}
#[cfg(not(feature = "enterprise"))]
@@ -6837,7 +6837,7 @@ async fn create_workspace_fork_branch(
}
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
@@ -7249,7 +7249,7 @@ async fn create_workspace_fork(
_check_nb_of_workspaces(&db).await?;
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&parent_workspace_id,
@@ -7592,7 +7592,7 @@ async fn attach_dev_workspace(
.fetch_optional(&db)
.await?
.unwrap_or(false);
if !is_admin_of_dev && !windmill_common::auth::is_super_admin_email(&db, &authed.email).await? {
if !is_admin_of_dev && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
return Err(Error::PermissionDenied(format!(
"Attaching workspace '{dev_w_id}' as a dev requires being an admin of it (or a superadmin)"
)));
@@ -8095,9 +8095,7 @@ async fn archive_workspace(
.fetch_optional(&db)
.await?
.unwrap_or(false);
if !is_prod_admin
&& !windmill_common::auth::is_super_admin_email(&db, &authed.email).await?
{
if !is_prod_admin && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
return Err(Error::PermissionDenied(format!(
"Archiving dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)"
)));
@@ -8172,6 +8170,7 @@ async fn leave_workspace(
Path(w_id): Path<String>,
authed: ApiAuthed,
) -> Result<String> {
windmill_api_auth::forbid_job_token_account_destruction(&authed)?;
let mut tx = db.begin().await?;
sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
@@ -8201,7 +8200,9 @@ async fn unarchive_workspace(
Path(w_id): Path<String>,
authed: ApiAuthed,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
// Global route (unarchives any workspace by id) gated on the caller's own
// is_admin claim, so it must reject a job token — see require_instance_admin.
require_instance_admin(&authed)?;
// Unarchiving re-activates a soft-deleted workspace, so it must respect the
// same CE workspace-count cap as creating one. The archived workspace is
@@ -10222,7 +10223,7 @@ async fn compare_workspaces(
// source AND the fork (superadmin satisfies both), which guarantees full
// visibility of every item on every side. `fork_authed.is_admin` already folds
// in superadmin; `authed.is_admin` (source side) does not, so OR it in.
let is_super_admin = windmill_common::auth::is_super_admin_email(&db, &authed.email).await?;
let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
let sees_all_items = is_super_admin || (authed.is_admin && fork_authed.is_admin);
let all_ahead_items_visible = all_ahead_items_visible || sees_all_items;
let all_behind_items_visible = all_behind_items_visible || sees_all_items;
@@ -10624,8 +10625,11 @@ async fn load_workspace_authed(
.await
.map_err(|e| Error::internal_err(e.to_string()))?;
let is_super_admin =
windmill_common::auth::is_super_admin_email(db, &base_authed.email).await?;
// Job-aware: this grants an admin claim in a workspace the caller may have no
// relationship with, and `job_id` is carried into the result — so a `WM_TOKEN`
// whose on-behalf identity is a superadmin would hold admin everywhere
// (GHSA-hfh4-cx4h-3fcr). It then falls through to its real membership below.
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?;
let user_row = sqlx::query!(
"SELECT username, is_admin, operator FROM usr
@@ -10650,6 +10654,7 @@ async fn load_workspace_authed(
is_session_token: base_authed.is_session_token,
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
job_id: base_authed.job_id,
});
};
@@ -10681,6 +10686,7 @@ async fn load_workspace_authed(
is_session_token: base_authed.is_session_token,
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
job_id: base_authed.job_id,
})
}
@@ -21,7 +21,6 @@ use windmill_audit::ActionKind;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::{
auth::is_super_admin_email,
db::UserDB,
error::{Error, Result},
utils::require_admin,
@@ -43,14 +42,14 @@ pub(crate) async fn change_workspace_id(
Extension(db): Extension<DB>,
Json(rw): Json<ChangeWorkspaceId>,
) -> Result<String> {
if *CLOUD_HOSTED && !is_super_admin_email(&db, &authed.email).await? {
if *CLOUD_HOSTED && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
return Err(Error::BadRequest(
"This feature is not available on the cloud".to_string(),
));
}
if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
} else {
require_admin(authed.is_admin, &authed.username)?;
}
@@ -929,7 +928,7 @@ pub(crate) async fn delete_workspace(
let mut tx = db.begin().await?;
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
&& !is_super_admin_email(&db, &authed.email).await?
&& !windmill_api_auth::is_super_admin_authed(&db, &authed).await?
{
return Err(Error::PermissionDenied(
"Deleting this workspace requires being the fork's owner or a superadmin".to_string(),
@@ -1297,7 +1296,7 @@ pub async fn drop_forked_datatable_databases(
let is_fork = workspace_is_fork(&db, &w_id).await?;
let mut tx = db.begin().await?;
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
&& !is_super_admin_email(&db, &authed.email).await?
&& !windmill_api_auth::is_super_admin_authed(&db, &authed).await?
{
return Err(Error::PermissionDenied(
"Dropping forked datatable databases requires being the fork's owner or a superadmin"
@@ -1454,7 +1453,7 @@ pub async fn drop_forked_ducklake_namespaces(
let is_fork = workspace_is_fork(&db, &w_id).await?;
let mut tx = db.begin().await?;
if !(is_fork && is_workspace_owner(&authed, &w_id, &mut tx).await?)
&& !is_super_admin_email(&db, &authed.email).await?
&& !windmill_api_auth::is_super_admin_authed(&db, &authed).await?
{
return Err(Error::PermissionDenied(
"Dropping forked ducklake namespaces requires being the fork's owner or a superadmin"
@@ -1959,7 +1958,7 @@ async fn require_prod_admin_for_dev_workspace(
.fetch_optional(db)
.await?
.unwrap_or(false);
if !is_prod_admin && !is_super_admin_email(db, &authed.email).await? {
if !is_prod_admin && !windmill_api_auth::is_super_admin_authed(db, &authed).await? {
return Err(Error::PermissionDenied(format!(
"Destroying dev workspace '{w_id}' or its data requires being an admin of its parent prod workspace '{prod}' (or a superadmin)"
)));
+46 -3
View File
@@ -84,7 +84,7 @@ use windmill_object_store::object_store_reexports::{Attribute, Attributes};
use windmill_store::resources::get_resource_value_interpolated_internal;
use windmill_api_auth::{
create_token_internal, ensure_scopes_within_caller, forbid_superadmin_job_token, NewToken,
create_token_internal, ensure_scopes_within_caller, forbid_elevated_job_token, NewToken,
OptJobAuthed,
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -1338,7 +1338,9 @@ async fn mint_raw_app_sdk_token(
) -> Result<(String, chrono::DateTime<chrono::Utc>)> {
// This credential outlives the request, so an ephemeral job token must not be
// able to launder itself into one — the reason `users/tokens/create` refuses.
forbid_superadmin_job_token(db, &authed.email, job_id).await?;
// The minted scopes do not contain it: `users/tokens/update_scopes` can widen
// any token of the same email.
forbid_elevated_job_token(db, &authed.email, job_id).await?;
// An embed token represents untrusted app JS; it must not bootstrap a
// broader SDK credential (same guard as `mint_app_embed_token`).
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
@@ -1411,7 +1413,7 @@ pub async fn build_embed_token_response(
_ => (None, None),
}
} else if policy.sandbox {
let resp = mint_app_embed_token(db, w_id, app_path, opt_authed).await?;
let resp = mint_app_embed_token(db, w_id, app_path, opt_authed, job_id).await?;
(resp.token, resp.expiration)
} else {
(None, None)
@@ -1535,8 +1537,13 @@ pub async fn mint_app_embed_token(
w_id: &str,
app_path: &str,
opt_authed: Option<&ApiAuthed>,
job_id: Option<uuid::Uuid>,
) -> Result<EmbedTokenResponse> {
let token_and_exp = if let Some(authed) = opt_authed {
// This credential outlives the request and its narrow scopes are not the
// boundary — `users/tokens/update_scopes` can widen any same-email token —
// so an elevated job token must not mint one (GHSA-hfh4-cx4h-3fcr).
forbid_elevated_job_token(db, &authed.email, job_id).await?;
// An app embed token represents untrusted app JS in the sandboxed iframe; it
// must never reach this mint path to renew itself. The 12h expiry is the
// blast-radius cap on a leaked embed token, and `ensure_scopes_within_caller`
@@ -2186,6 +2193,14 @@ async fn create_app_internal<'a>(
}
}
// Reject a forged superadmin run identity in the (possibly preserved) policy.
// Done on the non-RLS pool before the transaction below, like the resolution
// above, to avoid holding a second connection while `tx` is checked out.
windmill_common::auth::validate_on_behalf_of(
app.policy.on_behalf_of.as_deref(),
app.policy.on_behalf_of_email.as_deref(),
)?;
let mut tx = user_db.clone().begin(&authed).await?;
let path = app.path.clone();
if &app.path == "" {
@@ -3074,6 +3089,22 @@ async fn update_app_internal<'a>(
check_scopes(&authed, || format!("apps:write:{}", npath))?;
}
// Reject a forged superadmin run identity in a preserved policy. Mirror the
// `should_preserve` gate below (only a preserved value is caller-controlled;
// otherwise the policy is rewritten to the deployer's own identity) and run
// it on the non-RLS pool before the transaction to avoid a second connection.
if let Some(npolicy) = ns.policy.as_ref() {
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& npolicy.on_behalf_of.is_some();
if should_preserve {
windmill_common::auth::validate_on_behalf_of(
npolicy.on_behalf_of.as_deref(),
npolicy.on_behalf_of_email.as_deref(),
)?;
}
}
let mut tx = user_db.clone().begin(&authed).await?;
// `app_version.raw_app` is set by whichever endpoint writes the version, so a
@@ -5109,6 +5140,18 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
)
})?
.to_string();
// Defence in depth against a policy that already carries a forged superadmin
// sentinel (deployed before validation existed, or copied verbatim by a
// workspace fork): the sentinels are internal-only and never a legitimate app
// run identity, so refuse to execute rather than mint a superadmin token.
if windmill_common::auth::is_reserved_on_behalf_of_identity(
Some(&permissioned_as),
Some(&email),
) {
return Err(Error::BadRequest(
"app on_behalf_of is a reserved internal identity and cannot be executed".to_string(),
));
}
Ok((permissioned_as, email))
}
+8 -8
View File
@@ -197,10 +197,10 @@ struct SlowQueriesQuery {
}
async fn get_db_health(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<DbHealthResponse> {
require_super_admin(&db, &email).await?;
require_super_admin(&db, &authed).await?;
let (database_size, connection_pool, table_maintenance, slow_queries, datatables) = tokio::try_join!(
fetch_database_size(&db),
@@ -220,11 +220,11 @@ async fn get_db_health(
}
async fn get_db_health_jobs(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<DbHealthQuery>,
) -> JsonResult<DbHealthJobsResponse> {
require_super_admin(&db, &email).await?;
require_super_admin(&db, &authed).await?;
let scan_limit = query.scan_limit.unwrap_or(10_000).clamp(1_000, 1_000_000);
@@ -237,20 +237,20 @@ async fn get_db_health_jobs(
}
async fn get_slow_queries(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<SlowQueriesQuery>,
) -> JsonResult<Option<SlowQueriesInfo>> {
require_super_admin(&db, &email).await?;
require_super_admin(&db, &authed).await?;
let sort = query.sort.unwrap_or(SlowQuerySort::Total);
Ok(Json(fetch_slow_queries(&db, sort).await?))
}
async fn reset_slow_queries(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> windmill_common::error::Result<StatusCode> {
require_super_admin(&db, &email).await?;
require_super_admin(&db, &authed).await?;
sqlx::query("SELECT pg_stat_statements_reset()")
.execute(&db)
.await
+5 -6
View File
@@ -26,8 +26,6 @@ use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use url::Url;
use windmill_common::assets::AssetUsageAccessType;
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
use windmill_common::auth::is_super_admin_email;
use windmill_common::auth::TOKEN_PREFIX_LEN;
#[cfg(feature = "run_inline")]
use windmill_common::client::AuthedClient;
@@ -2621,7 +2619,7 @@ async fn send_email_with_instance_smtp(
let is_handler_job = authed.email == EMAIL_ERROR_HANDLER_USER_EMAIL
|| authed.email == SCHEDULE_ERROR_HANDLER_USER_EMAIL;
if !is_handler_job && !is_super_admin_email(&db, &authed.email).await? {
if !is_handler_job && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
return Err(Error::NotAuthorized(
"Only super admin or whitelisted token can access email workspace error handler feature"
.to_string(),
@@ -8904,7 +8902,7 @@ async fn add_batch_jobs(
Path((w_id, n)): Path<(String, i32)>,
Json(batch_info): Json<BatchInfo>,
) -> error::JsonResult<Vec<Uuid>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let (
hash,
@@ -10880,11 +10878,11 @@ struct TagCount {
}
async fn count_by_tag(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<CountByTagQuery>,
) -> JsonResult<Vec<TagCount>> {
require_super_admin(&db, &email).await?;
require_super_admin(&db, &authed).await?;
let horizon = query.horizon_secs.unwrap_or(3600); // Default to 1 hour if not specified
let counts = sqlx::query_as!(
@@ -11618,6 +11616,7 @@ mod approval_view_gate_tests {
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
+1
View File
@@ -374,6 +374,7 @@ async fn inject_agent_authed(
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
},
job_id: None,
});
@@ -18,6 +18,7 @@ use windmill_common::{
};
use crate::db::ApiAuthed;
use windmill_api_auth::forbid_elevated_job_token;
use windmill_mcp::parse_mcp_scopes;
/// Token expiration for MCP OAuth tokens (1 week in seconds)
@@ -763,6 +764,13 @@ async fn oauth_approve_inner(
workspace_id: &str,
form: ApprovalForm,
) -> Result<Json<ApprovalResponse>> {
// The code approved here is exchanged for a database token carrying only this
// email, so an elevated job token would launder its identity into a credential
// with no `job_id` — and the MCP gateway would then re-enter the API uncapped
// (GHSA-hfh4-cx4h-3fcr). Guarded at the shared inner fn: both the workspaced and
// the gateway approve route reach the exchange through here.
forbid_elevated_job_token(db, &authed.email, authed.job_id).await?;
// Verify user is a member of the workspace
let is_member = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)",
+113 -4
View File
@@ -634,11 +634,22 @@ pub async fn create_http_request(
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?;
// Add authorization header
// Add authorization header. Carry the caller's job provenance into the proxy
// JWT: a job's WM_TOKEN is capped at workspace admin (GHSA-hfh4-cx4h-3fcr), and
// dropping `job_id` here would re-mint an uncapped token that satisfies
// require_super_admin / require_devops_role on the proxied route.
let authed = Authed::from(api_authed.clone());
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, scopes)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let token = create_jwt_token(
authed,
workspace_id,
3600,
api_authed.job_id,
None,
None,
scopes,
)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
// Add body if present
@@ -1102,4 +1113,102 @@ mod tests {
Some("((o.path = 'f/a_b' OR o.path LIKE 'f/a\\_b/%' ESCAPE '\\'))".to_string())
);
}
fn test_api_authed(job_id: Option<uuid::Uuid>) -> ApiAuthed {
ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".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,
}
}
/// Capture the `Authorization` header of the single request `create_http_request`
/// proxies, decode the minted JWT, and return its `job_id` claim.
async fn proxied_jwt_job_id(caller: &ApiAuthed) -> Option<String> {
use axum::{extract::State, routing::get, Router};
use std::sync::{Arc, Mutex};
use windmill_common::auth::JWTAuthClaims;
// The internal JWT secret must be non-empty for encode/decode to round-trip.
windmill_common::jwt::JWT_SECRET.store(Arc::new("mytestsecret".to_string()));
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let app = Router::new()
.route(
"/",
get(
|State(state): State<Arc<Mutex<Option<String>>>>,
headers: axum::http::HeaderMap| async move {
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
*state.lock().unwrap() =
Some(auth.to_str().unwrap_or_default().to_string());
}
"ok"
},
),
)
.with_state(captured.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let url = format!("http://{addr}/");
create_http_request("GET", &url, "test-workspace", caller, None)
.await
.expect("proxied request should succeed");
server.abort();
let header = captured
.lock()
.unwrap()
.clone()
.expect("no auth header captured");
let token = header.strip_prefix("Bearer ").unwrap().to_string();
let jwt = token
.strip_prefix("jwt_")
.expect("expected an internal jwt_ token");
let claims: JWTAuthClaims = windmill_common::jwt::decode_with_internal_secret(jwt)
.await
.unwrap();
claims.job_id
}
/// Regression for GHSA-hfh4-cx4h-3fcr: the MCP proxy must carry the caller's
/// job provenance into the JWT it mints, otherwise a job's WM_TOKEN — capped at
/// workspace admin — would be re-minted uncapped and pass require_super_admin /
/// require_devops_role on the proxied route (e.g. listWorkers).
#[tokio::test]
async fn create_http_request_preserves_job_id_provenance() {
let job_id = uuid::Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
assert_eq!(
proxied_jwt_job_id(&test_api_authed(Some(job_id))).await,
Some(job_id.to_string()),
"a job-token caller's job_id must be preserved in the proxied JWT"
);
}
/// The mirror invariant: a non-job caller must not gain a spurious job_id (which
/// would wrongly cap a legitimate interactive/superadmin MCP token).
#[tokio::test]
async fn create_http_request_keeps_non_job_caller_unstamped() {
assert_eq!(
proxied_jwt_job_id(&test_api_authed(None)).await,
None,
"a non-job caller must not be stamped with a job_id"
);
}
}
+2 -2
View File
@@ -463,7 +463,7 @@ pub(crate) async fn global_offboard_preview(
Extension(db): Extension<DB>,
Path(email): Path<String>,
) -> JsonResult<GlobalOffboardPreview> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let workspaces = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1",
@@ -492,7 +492,7 @@ pub(crate) async fn offboard_global_user(
Path(email): Path<String>,
Json(req): Json<GlobalOffboardRequest>,
) -> Result<Json<OffboardResponse>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let workspaces = sqlx::query!(
+4 -4
View File
@@ -43,12 +43,12 @@ pub struct LogFile {
pub json_fmt: bool,
}
async fn list_files(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(pagination): Query<Pagination>,
Query(lq): Query<LogFileQuery>,
) -> JsonResult<Vec<LogFile>> {
require_devops_role(&db, &email).await?;
require_devops_role(&db, &authed).await?;
let (per_page, offset) = windmill_common::utils::paginate(pagination);
let mut sqlb = sql_builder::SqlBuilder::select_from("log_file")
@@ -89,13 +89,13 @@ async fn list_files(
}
async fn get_log_file(
ApiAuthed { email, .. }: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(path): Path<windmill_common::utils::StripPath>,
) -> windmill_common::error::Result<Response> {
use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE;
require_devops_role(&db, &email).await?;
require_devops_role(&db, &authed).await?;
let path = path.to_path();
if path.contains("..") {
return Err(Error::BadRequest("Invalid path".to_string()));
+10 -5
View File
@@ -21,7 +21,9 @@ use axum::{
};
use hyper::StatusCode;
use serde::Deserialize;
use windmill_api_auth::{forbid_superadmin_job_token, require_super_admin};
use windmill_api_auth::{
forbid_elevated_job_token, forbid_superadmin_job_token, require_super_admin,
};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
@@ -115,7 +117,7 @@ async fn list_ext_jwt_tokens(
Extension(db): Extension<DB>,
Query(query): Query<ListExtJwtTokensQuery>,
) -> Result<Json<Vec<ExternalJwtToken>>> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination {
page: query.page,
@@ -146,7 +148,10 @@ async fn set_password(
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(ep): Json<EditPassword>,
) -> Result<String> {
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
// Choosing the password of the elevated account this job runs as is a credential
// mint by another name: logging in with it yields a session with no `job_id`
// (GHSA-hfh4-cx4h-3fcr).
forbid_elevated_job_token(&db, &authed.email, job_id).await?;
let email = authed.email.clone();
crate::users_oss::set_password(db, argon2, authed, &email, ep).await
}
@@ -159,7 +164,7 @@ async fn set_password_of_user(
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(ep): Json<EditPassword>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
crate::users_oss::set_password(db, argon2, authed, &email, ep).await
}
@@ -176,7 +181,7 @@ async fn rename_user(
Extension(db): Extension<DB>,
Json(ru): Json<RenameUser>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
+2 -2
View File
@@ -214,7 +214,7 @@ pub async fn get_critical_alerts(
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<serde_json::Value> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, &db).await?;
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
}
@@ -230,7 +230,7 @@ pub async fn acknowledge_critical_alert(
Path((w_id, id)): Path<(String, i32)>,
authed: ApiAuthed,
) -> Result<String> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, &db).await?;
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await
}
+77 -1
View File
@@ -325,6 +325,49 @@ pub async fn is_super_admin_email<'c>(db: impl sqlx::PgExecutor<'c>, email: &str
Ok(is_admin)
}
/// The three reserved internal identities that grant instance-superadmin at
/// execution: `superadmin_secret@` / `superadmin_notification@` (matched on the
/// email) and `superadmin_sync@` (matched on `permissioned_as`). They belong to
/// no real user, so a stored `on_behalf_of` (app policy, flow/script,
/// schedule, trigger) must never carry one as either field — it would be a
/// forged superadmin run identity. Mirror of the `is_super_admin` derivation in
/// [`fetch_authed_from_permissioned_as_inner`].
pub fn is_reserved_on_behalf_of_identity(
permissioned_as: Option<&str>,
on_behalf_of_email: Option<&str>,
) -> bool {
const RESERVED: [&str; 3] = [
SUPERADMIN_SECRET_EMAIL,
SUPERADMIN_NOTIFICATION_EMAIL,
SUPERADMIN_SYNC_EMAIL,
];
[permissioned_as, on_behalf_of_email]
.into_iter()
.flatten()
.any(|v| RESERVED.contains(&v))
}
/// Guard a caller-supplied `on_behalf_of` before it is persisted on a deployable
/// object (app policy, flow/script, schedule, trigger): reject the reserved
/// internal sentinels, which no legitimate deploy ever carries. The actual
/// escalation is closed at execution by the job-token cap in
/// [`require_super_admin`] — even a superadmin `on_behalf_of` yields a token
/// capped at workspace admin — so this is a cheap, non-breaking early guard, not
/// the primary defense. It deliberately does *not* restrict deploying on behalf
/// of a real user (including a real superadmin, e.g. git-sync of
/// superadmin-authored content), which is the intended `wm_deployers` capability.
pub fn validate_on_behalf_of(
permissioned_as: Option<&str>,
on_behalf_of_email: Option<&str>,
) -> Result<()> {
if is_reserved_on_behalf_of_identity(permissioned_as, on_behalf_of_email) {
return Err(Error::BadRequest(
"on_behalf_of cannot be a reserved internal identity".to_string(),
));
}
Ok(())
}
pub async fn is_devops_email(db: &DB, email: &str) -> Result<bool> {
if is_super_admin_email(db, email).await? {
return Ok(true);
@@ -727,8 +770,11 @@ pub mod aws {
#[cfg(test)]
mod tests {
use super::is_user_token;
use super::{is_reserved_on_behalf_of_identity, is_user_token};
use super::{job_token_remaining_lifetime_secs, JWTAuthClaims, JOB_TOKEN_REFRESH_MARGIN_SECS};
use crate::users::{
SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL,
};
fn job_jwt(exp_offset_secs: i64) -> String {
let claims = JWTAuthClaims {
@@ -769,6 +815,36 @@ mod tests {
assert!(job_token_remaining_lifetime_secs("").is_none());
}
#[test]
fn reserved_on_behalf_of_identity_matches_every_sentinel_in_either_field() {
// Matched on the email (secret / notification) or on permissioned_as (sync).
assert!(is_reserved_on_behalf_of_identity(
None,
Some(SUPERADMIN_SECRET_EMAIL)
));
assert!(is_reserved_on_behalf_of_identity(
None,
Some(SUPERADMIN_NOTIFICATION_EMAIL)
));
assert!(is_reserved_on_behalf_of_identity(
Some(SUPERADMIN_SYNC_EMAIL),
None
));
// A sentinel smuggled as a raw-email permissioned_as (schedules/triggers
// derive the email from it) is caught too.
assert!(is_reserved_on_behalf_of_identity(
Some(SUPERADMIN_SECRET_EMAIL),
None
));
// Ordinary identities pass.
assert!(!is_reserved_on_behalf_of_identity(None, None));
assert!(!is_reserved_on_behalf_of_identity(
Some("u/alice"),
Some("alice@example.com")
));
assert!(!is_reserved_on_behalf_of_identity(Some("g/team"), None));
}
#[test]
fn user_tokens_are_editable() {
assert!(is_user_token(None)); // no label
+5 -3
View File
@@ -10,7 +10,6 @@ use tokio::io::AsyncReadExt;
pub use windmill_types::jobs::*;
use crate::{
auth::is_super_admin_email,
client::AuthedClient,
db::{AuthedRef, UserDbWithAuthed, DB},
error::{self, to_anyhow, Error},
@@ -335,11 +334,14 @@ lazy_static::lazy_static! {
).unwrap_or(false);
}
// `is_super_admin` is passed in (not derived from an email here) so callers can
// make it job-token-aware: a job's WM_TOKEN must never count as superadmin
// (GHSA-hfh4-cx4h-3fcr). See `is_super_admin_authed` at the request wrapper.
pub async fn check_tag_available_for_workspace_internal(
db: &DB,
w_id: &str,
tag: &str,
email: &str,
is_super_admin: bool,
scope_tags: Option<Vec<&str>>,
) -> error::Result<()> {
let mut is_tag_in_scope_tags = None;
@@ -372,7 +374,7 @@ pub async fn check_tag_available_for_workspace_internal(
_ => {}
}
if !is_super_admin_email(db, email).await? {
if !is_super_admin {
if scope_tags.is_some() && is_tag_in_scope_tags.is_some() {
return Err(Error::BadRequest(format!(
"Tag {tag} is not available in your scope"
+10 -3
View File
@@ -29,10 +29,9 @@ use sqlx::{Acquire, Postgres};
pub mod agent_workers;
pub mod apps;
pub mod assets;
pub mod azure_workload_identity;
pub mod dbt_manifest;
pub mod audit;
pub mod auth;
pub mod azure_workload_identity;
#[cfg(feature = "benchmark")]
pub mod bench;
pub mod cache;
@@ -44,6 +43,7 @@ mod db_entra_ee;
#[cfg(all(feature = "enterprise", feature = "private"))]
mod db_iam_ee;
pub mod db_params;
pub mod dbt_manifest;
pub mod deploy_origin;
#[cfg(feature = "private")]
pub mod deployment_requests_ee;
@@ -236,6 +236,10 @@ pub async fn resolve_on_behalf_of(
if !(preserve && can_preserve_on_behalf_of(authed)) {
return reject_unenqueueable(users::username_to_permissioned_as(authed.username()));
}
// Reserved superadmin sentinels are rejected by name, before resolution: the lookups
// below only reject them while no account holds their address, and the runtime grants
// superadmin on these emails by string comparison alone.
auth::validate_on_behalf_of(on_behalf_of, on_behalf_of_email)?;
let permissioned_as = match on_behalf_of {
Some(permissioned_as) => {
// The principal wins, but a caller that also names a contradictory address has a
@@ -1760,7 +1764,10 @@ pub async fn on_behalf_of_from_permissioned_as(
// processes, so a cached read would keep minting jobs under an address the account no longer
// holds for up to a minute after it moves.
let email = users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db).await?;
Ok(Some(jobs::OnBehalfOf { email, permissioned_as: permissioned_as.to_string() }))
Ok(Some(jobs::OnBehalfOf {
email,
permissioned_as: permissioned_as.to_string(),
}))
}
impl ScriptHashInfo<ScriptRunnableSettingsHandle> {
+7 -1
View File
@@ -330,10 +330,16 @@ pub async fn require_admin_or_devops(
is_admin: bool,
username: &str,
email: &str,
// True when the caller is a job token (`$WM_TOKEN`). `devops` is instance-level
// and `is_devops_email` is true for superadmins, so a job token whose on_behalf_of
// a `wm_deployers` member pointed at a superadmin would otherwise clear the devops
// branch on a workspace it isn't admin of (GHSA-hfh4-cx4h-3fcr). Workspace admin
// (`is_admin`) stays allowed — that is the cap ceiling.
is_job_token: bool,
db: &DB,
) -> Result<()> {
if !is_admin {
if !is_devops_email(db, email).await? {
if is_job_token || !is_devops_email(db, email).await? {
return Err(Error::RequireAdmin(username.to_string()));
}
}
+2 -1
View File
@@ -513,11 +513,12 @@ pub async fn push_scheduled_job<'c>(
};
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
let is_super_admin = windmill_common::auth::is_super_admin_email(db, &email).await?;
check_tag_available_for_workspace_internal(
db,
&schedule.workspace_id,
&tag,
&email,
is_super_admin,
None, // no token for schedules so no scopes so no scope_tags
)
.warn_after_seconds_with_sql(1, "check_tag_available_for_workspace_internal".to_string())
+11 -2
View File
@@ -13,7 +13,7 @@ use std::sync::LazyLock;
use windmill_api_auth::{
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
require_super_admin, ApiAuthed, Tokened,
require_super_admin_email, ApiAuthed, Tokened,
};
use windmill_common::db::DB;
use windmill_common::per_minute_counter::PerMinuteCounter;
@@ -724,7 +724,16 @@ pub async fn get_resource_value_interpolated_internal<'a>(
) -> Result<Option<serde_json::Value>> {
// This is a special syntax to help debugging custom instance databases
if let Some(dbname) = path.strip_prefix("CUSTOM_INSTANCE_DB/") {
require_super_admin(db_with_opt_authed.db(), &db_with_opt_authed.email()).await?;
// A job's WM_TOKEN must never reach this superadmin-only path even if it
// runs on behalf of a superadmin (GHSA-hfh4-cx4h-3fcr). Read the job
// provenance from the *authenticated* identity, never the caller-supplied
// `job_id` param (which comes from an untrusted query string).
if db_with_opt_authed.authed().and_then(|a| a.job_id).is_some() {
return Err(Error::NotAuthorized(
"CUSTOM_INSTANCE_DB cannot be resolved from a job token ($WM_TOKEN)".to_string(),
));
}
require_super_admin_email(db_with_opt_authed.db(), &db_with_opt_authed.email()).await?;
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
pg_creds.dbname = dbname.to_string();
let pg_creds = serde_json::to_value(&pg_creds)
+17
View File
@@ -572,6 +572,14 @@ async fn create_trigger<T: TriggerCrud>(
}
}
// Reject a forged superadmin run identity in a preserved permissioned_as
// (the sentinel guard; a trigger's email is derived from it at execution).
let resolved_permissioned_as = new_trigger.base.resolve_permissioned_as(&authed);
windmill_common::auth::validate_on_behalf_of(
Some(&resolved_permissioned_as),
None,
)?;
let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation(
new_trigger.base.permissioned_as.as_deref(),
new_trigger.base.preserve_permissioned_as.unwrap_or(false),
@@ -825,6 +833,15 @@ async fn update_trigger<T: TriggerCrud>(
let new_path = edit_trigger.base.path.to_string();
let labels = edit_trigger.base.labels.clone();
// Reject a forged superadmin run identity in a preserved permissioned_as
// (the sentinel guard; a trigger's email is derived from it at execution).
let resolved_permissioned_as = edit_trigger.base.resolve_permissioned_as(&authed);
windmill_common::auth::validate_on_behalf_of(
Some(&resolved_permissioned_as),
None,
)?;
let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation(
edit_trigger.base.permissioned_as.as_deref(),
edit_trigger.base.preserve_permissioned_as.unwrap_or(false),
+3 -1
View File
@@ -4421,11 +4421,13 @@ async fn push_next_flow_job(
.as_deref()
.filter(|t| !t.is_empty() && *t != flow_job.tag.as_str())
{
let is_super_admin =
windmill_common::auth::is_super_admin_email(db, email).await?;
check_tag_available_for_workspace_internal(
db,
&flow_job.workspace_id,
tag_str,
email,
is_super_admin,
None, // no token for flow substeps so no scopes so no scope_tags
)
.warn_after_seconds_with_sql(