Files
windmill/backend/windmill-api-auth/src/auth.rs
T
c2deea13b7 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>
2026-08-19 22:33:46 +02:00

1006 lines
42 KiB
Rust

#[cfg(feature = "enterprise")]
use crate::ee_oss::ExternalJwks;
use axum::{
extract::{FromRequestParts, OriginalUri, Query},
Extension, Json,
};
use chrono::TimeZone;
use http::{request::Parts, StatusCode};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use tower_cookies::Cookies;
use tracing::Span;
use crate::{ApiAuthed, OptJobAuthed};
use std::{
str::FromStr,
sync::{
atomic::{AtomicI64, AtomicU64, Ordering},
Arc,
},
};
#[cfg(feature = "enterprise")]
use tokio::sync::RwLock;
use windmill_common::DB;
use windmill_common::{
auth::{
get_folders_for_user, get_groups_for_user, hash_token, is_session_label, safe_token_prefix,
JWTAuthClaims,
},
error::{Error, JsonResult},
jwt,
usernames::get_instance_username_or_fallback_to_email,
users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL},
};
lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
// Cache for token -> email lookups (for non-workspace-member authenticated users)
static ref TOKEN_EMAIL_CACHE: Cache<String, (Option<String>, std::time::Instant)> = Cache::new(500);
}
/// A token keeps its identity when a superadmin moves the account to another address, so entries
/// here must expire on their own; nothing invalidates them by token hash.
const TOKEN_EMAIL_CACHE_TTL_SECS: u64 = 60;
/// Get email from a valid token, with caching.
/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member.
async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
let t_hash = hash_token(token);
if let Some((cached, cached_at)) = TOKEN_EMAIL_CACHE.get(&t_hash) {
if cached_at.elapsed().as_secs() < TOKEN_EMAIL_CACHE_TTL_SECS {
return cached;
}
}
let email = sqlx::query_scalar!(
"SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)",
t_hash
)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten(); // email column is nullable, so we get Option<Option<String>>
TOKEN_EMAIL_CACHE.insert(t_hash, (email.clone(), std::time::Instant::now()));
email
}
/// Get end user email from authenticated user or token.
/// Returns email if user is authenticated (workspace member) or has valid instance token.
pub async fn get_end_user_email(
db: &DB,
opt_authed: Option<&ApiAuthed>,
token: Option<&str>,
) -> Option<String> {
if let Some(authed) = opt_authed {
return Some(authed.email.clone());
}
if let Some(token) = token {
return get_email_from_token(db, token).await;
}
None
}
// Global function to invalidate tokens from cache by prefix
pub fn invalidate_token_from_cache(token_prefix: &str) {
// Remove all cache entries whose raw token starts with this prefix (across all workspaces)
AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| {
!cached_token.starts_with(token_prefix)
});
tracing::info!(
"Invalidated token(s) from auth cache with prefix: {}...",
&token_prefix[..token_prefix.len().min(8)]
);
}
#[derive(Clone)]
pub struct ExpiringAuthCache {
pub authed: ApiAuthed,
pub expiry: chrono::DateTime<chrono::Utc>,
pub job_id: Option<uuid::Uuid>,
}
pub struct AuthCache {
db: DB,
superadmin_secret: Option<String>,
#[cfg(feature = "enterprise")]
ext_jwks: Option<Arc<RwLock<ExternalJwks>>>,
}
impl AuthCache {
pub fn new(
db: DB,
superadmin_secret: Option<String>,
#[cfg(feature = "enterprise")] ext_jwks: Option<Arc<RwLock<ExternalJwks>>>,
) -> Self {
AuthCache {
db,
superadmin_secret,
#[cfg(feature = "enterprise")]
ext_jwks,
}
}
pub async fn invalidate(&self, w_id: &str, token: String) {
AUTH_CACHE.remove(&(w_id.to_string(), token));
}
pub async fn get_authed(&self, w_id: Option<String>, token: &str) -> Option<ApiAuthed> {
Some(self.get_opt_job_authed(w_id, token).await?.authed)
}
pub async fn get_opt_job_authed(
&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,
// which re-validates the request token per workspace) don't reject the
// fabricated token.
if is_no_auth() {
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
}
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
);
let s = AUTH_CACHE.get(&key).map(|c| c.to_owned());
match s {
Some(ExpiringAuthCache { authed, expiry, job_id }) if expiry > chrono::Utc::now() => {
Some(OptJobAuthed { authed, job_id })
}
#[cfg(feature = "enterprise")]
_ if token.starts_with("jwt_ext_") => {
let authed_and_exp = match crate::ee_oss::jwt_ext_auth(
w_id.as_ref(),
token.trim_start_matches("jwt_ext_"),
self.ext_jwks.clone(),
&self.db,
)
.await
{
Ok(r) => Some(r),
Err(e) => {
tracing::error!("JWT_EXT auth error: {:?}", e);
None
}
};
if let Some((authed, exp, job_id)) = authed_and_exp.clone() {
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc.timestamp_nanos(exp as i64 * 1_000_000_000),
job_id,
},
);
Some(OptJobAuthed { authed, job_id })
} else {
None
}
}
_ if token.starts_with("jwt_") => {
let jwt_token = token.trim_start_matches("jwt_");
let jwt_result = jwt::decode_with_internal_secret::<JWTAuthClaims>(jwt_token).await;
match jwt_result {
Ok(claims) => {
if w_id.is_some_and(|w_id| !claims.allowed_in_workspace(&w_id)) {
tracing::error!("JWT auth error: workspace_id mismatch");
return None;
}
let is_session_token = is_session_label(claims.label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(claims.label);
let authed = ApiAuthed {
email: claims.email,
username: claims.username,
is_admin: claims.is_admin,
is_operator: claims.is_operator,
groups: claims.groups,
folders: claims.folders,
// Honor the scopes embedded in the JWT (mirrors the EE
// jwt_ext_ branch). The route middleware only enforces
// scopes when Some, so a None-scoped JWT (e.g. the job
// WM_TOKEN) keeps full user privileges as before.
scopes: claims.scopes,
username_override,
username_override_is_token_label,
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,
};
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc
.timestamp_nanos(claims.exp as i64 * 1_000_000_000),
job_id,
},
);
Some(OptJobAuthed { authed, job_id })
}
Err(err) => {
tracing::error!("JWT auth error: {:?}", err);
None
}
}
}
_ => {
let t_hash = hash_token(token);
let user_o = sqlx::query!(
"UPDATE token SET last_used_at = now() WHERE
token_hash = $1
AND (expiration > NOW() OR expiration IS NULL)
AND (workspace_id IS NULL OR workspace_id = $2)
RETURNING owner, email, super_admin, scopes, label, read_only",
t_hash,
w_id.as_ref(),
)
.map(|x| {
(
x.owner,
x.email,
x.super_admin,
x.scopes,
x.label,
x.read_only,
)
})
.fetch_optional(&self.db)
.await
.ok()
.flatten();
if let Some(user) = user_o {
let authed_o = {
match user {
(Some(owner), Some(email), super_admin, _, label, read_only)
if w_id.is_some() =>
{
let is_session_token = is_session_label(label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {
let lookup = if super_admin {
Some((true, false))
} else {
sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
)
.fetch_optional(&self.db)
.await
.ok()
.flatten()
.map(|r| (r.is_admin, r.operator))
};
if let Some((is_admin, is_operator)) = lookup {
let w_id = &w_id.unwrap();
let groups =
get_groups_for_user(w_id, &name, &email, &self.db)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(
w_id, &name, &groups, &self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
} else {
tracing::warn!(
"Token owner u/{} is not a member of workspace {}; rejecting auth",
name,
w_id.as_deref().unwrap_or("")
);
None
}
} else if prefix == "g" {
let group_exists = if super_admin {
true
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)",
&w_id.as_ref().unwrap(),
name,
)
.fetch_one(&self.db)
.await
.ok()
.flatten()
.unwrap_or(false)
};
if group_exists {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
"",
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: format!(
"{}{name}",
windmill_common::users::USERNAME_GROUP_PREFIX
),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
} else {
tracing::warn!(
"Token owner g/{} is not a group in workspace {}; rejecting auth",
name,
w_id.as_deref().unwrap_or("")
);
None
}
} else {
tracing::warn!(
"Token owner '{}' has unrecognised prefix '{}'; rejecting auth",
owner,
prefix
);
None
}
} else {
tracing::warn!(
"Token owner '{}' is missing a prefix (expected u/ or g/); rejecting auth",
owner
);
None
}
}
(_, Some(email), super_admin, scopes, label, read_only) => {
let is_session_token = is_session_label(label.as_deref());
let (username_override, username_override_is_token_label) =
username_override_from_label(label);
if w_id.is_some() {
let row_o = sqlx::query!(
"SELECT username, is_admin, operator FROM usr WHERE
email = $1 AND workspace_id = $2 AND disabled = false",
&email,
w_id.as_ref().unwrap()
)
.map(|x| (x.username, x.is_admin, x.operator))
.fetch_optional(&self.db)
.await
.unwrap_or(Some(("error".to_string(), false, false)));
match row_o {
Some((username, is_admin, is_operator)) => {
let groups = get_groups_for_user(
&w_id.as_ref().unwrap(),
&username,
&email,
&self.db,
)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(
&w_id.unwrap(),
&username,
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email,
username,
is_admin: is_admin || super_admin,
is_operator,
groups,
folders,
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
}
None if super_admin => {
// Fail closed on a DB error rather than
// letting the email leak in as the username.
match get_instance_username_or_fallback_to_email(
&self.db, &email,
)
.await
{
Ok(username) => Some(ApiAuthed {
email,
username,
is_admin: super_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
}),
Err(e) => {
tracing::error!(
"Failed to resolve instance username for superadmin {email}: {e:#}"
);
None
}
}
}
None => None,
}
} else {
Some(ApiAuthed {
email: email.to_string(),
username: email,
is_admin: super_admin,
is_operator: true,
groups: Vec::new(),
folders: Vec::new(),
scopes,
username_override,
username_override_is_token_label,
is_session_token,
token_prefix: Some(safe_token_prefix(token)),
read_only,
job_id: None,
})
}
}
_ => None,
}
};
if let Some(authed) = authed_o.as_ref() {
AUTH_CACHE.insert(
key,
ExpiringAuthCache {
authed: authed.clone(),
expiry: chrono::Utc::now()
+ chrono::Duration::try_seconds(120).unwrap(),
job_id: None,
},
);
}
authed_o.map(|authed| OptJobAuthed { authed, job_id: None })
} else if self
.superadmin_secret
.as_ref()
.map(|x| x == token)
.unwrap_or(false)
{
let authed = ApiAuthed {
email: SUPERADMIN_SECRET_EMAIL.to_string(),
username: "superadmin_secret".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
job_id: None,
};
Some(OptJobAuthed { authed, job_id: None })
} else {
None
}
}
}
}
}
pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
let auth_header = parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
let from_cookie = match auth_header {
Some(x) => Some(x.to_owned()),
None => Extension::<Cookies>::from_request_parts(parts, state)
.await
.ok()
.and_then(|cookies| {
cookies
.get(COOKIE_NAME)
.map(|c| c.value_trimmed().to_owned())
}),
};
#[derive(Deserialize)]
struct Token {
token: Option<String>,
}
match from_cookie {
Some(token) => Some(token),
None => Query::<Token>::from_request_parts(parts, state)
.await
.ok()
.and_then(|token| token.token.clone()),
}
}
#[derive(Clone, Debug)]
pub struct Tokened {
pub token: String,
}
#[derive(Clone, Debug)]
pub struct OptTokened {
#[allow(dead_code)]
pub token: Option<String>,
}
struct BruteForceCounter {
counter: AtomicU64,
last_reset: AtomicI64,
}
lazy_static::lazy_static! {
static ref BRUTE_FORCE_COUNTER: BruteForceCounter =
BruteForceCounter { last_reset: AtomicI64::new(0), counter: AtomicU64::new(0) };
}
impl BruteForceCounter {
async fn increment(&self) {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
if self.counter.fetch_add(1, Ordering::Relaxed) > 10000 {
tracing::error!(
"Brute force attack to find valid token detected, sleeping unauthorized response for 2 seconds"
);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if now - self.last_reset.load(Ordering::Relaxed) > 60 {
self.counter.store(0, Ordering::Relaxed);
self.last_reset.store(now, Ordering::Relaxed);
}
}
}
impl<S> FromRequestParts<S> for Tokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(Tokened { token: "".to_string() });
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(tokened.clone())
} else {
let token_o = extract_token(parts, state).await;
if let Some(token) = token_o {
let tokened = Self { token };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else if is_no_auth() {
// In `--no-auth` mode requests carry no token, but handlers that
// also require Tokened (e.g. global_whoami) must still resolve.
let tokened = Self { token: "no_auth".to_string() };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
}
}
}
}
impl<S> FromRequestParts<S> for OptTokened
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
if parts.method == http::Method::OPTIONS {
return Ok(OptTokened { token: None });
};
let already_tokened = parts.extensions.get::<Tokened>();
if let Some(tokened) = already_tokened {
Ok(OptTokened { token: Some(tokened.token.clone()) })
} else {
let token_o = extract_token(parts, state).await;
Ok(OptTokened { token: token_o })
}
}
}
pub fn transform_old_scope_to_new_scope(scopes: Option<&mut Vec<String>>) {
if let Some(scopes) = scopes {
for scope in scopes.iter_mut() {
if scope.starts_with("run:") {
let (_, part_scope) = scope.split_once(":").unwrap();
if let Some((kind, path)) = part_scope.split_once("/") {
//appending a 's' as runnable kind is singular while new scope format expect it to be plural
*scope = format!("jobs:run:{}s:{}", kind, path);
}
} else if scope.starts_with("jobs:") {
// Map old jobs scopes to new format
let new_scope = match scope.as_str() {
"jobs:listjobs" => "jobs:read",
"jobs:runscript" => "jobs:run:scripts",
"jobs:runflow" => "jobs:run:flows",
"jobs:resumeflow" => "jobs:run:flows",
"jobs:deletejob" => "jobs:write",
_ => continue,
};
*scope = new_scope.to_string();
}
}
}
}
fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
Some(path_vec[3].to_owned())
} else if path_vec.len() >= 5
&& path_vec[0] == ""
&& path_vec[1] == "api"
&& path_vec[2] == "mcp"
&& path_vec[3] == "w"
{
Some(path_vec[4].to_owned())
} else {
if path_vec.len() >= 5 && path_vec[0] == "" && path_vec[2] == "srch" && path_vec[3] == "w" {
Some(path_vec[4].to_owned())
} else {
None
}
};
workspace_id
}
/// `--no-auth` mode: compiled-in `oss` builds, or the `NO_AUTH` runtime flag on
/// any build (the runtime flag is force-disabled on CLOUD_HOSTED). When on,
/// every request resolves as the admin superadmin so a fronting gateway can
/// handle authentication instead.
pub fn is_no_auth() -> bool {
cfg!(feature = "no_auth") || *windmill_common::worker::NO_AUTH
}
/// The synthetic superadmin identity returned for every request in no-auth mode.
fn no_auth_admin_authed() -> ApiAuthed {
ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
}
}
/// Resolves OptJobAuthed from request parts.
/// Takes ownership of Parts and returns them back.
#[allow(unreachable_code, unused_mut)]
pub async fn resolve_opt_job_authed(
mut parts: Parts,
) -> std::result::Result<(OptJobAuthed, Parts), (Error, Parts)> {
if parts.method == http::Method::OPTIONS {
return Ok((OptJobAuthed::default(), parts));
};
if is_no_auth() {
return Ok((
OptJobAuthed { authed: no_auth_admin_authed(), job_id: None },
parts,
));
}
let already_authed = parts.extensions.get::<OptJobAuthed>().cloned();
if let Some(authed) = already_authed {
return Ok((authed, parts));
}
let already_tokened = parts.extensions.get::<Tokened>().cloned();
let token_o = if let Some(token) = already_tokened {
Some(token.token.clone())
} else {
extract_token(&mut parts, &()).await
};
if let Some(token) = token_o {
if let Ok(Extension(cache)) =
Extension::<Arc<AuthCache>>::from_request_parts(&mut parts, &()).await
{
let original_uri = OriginalUri::from_request_parts(&mut parts, &())
.await
.ok()
.map(|x| x.0)
.unwrap_or_default();
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
let workspace_id = maybe_get_workspace_id_from_path(&path_vec).or_else(|| {
parts
.extensions
.get::<windmill_common::db::GatewayWorkspaceId>()
.map(|g| g.0.clone())
});
if let Some(mut opt_job_authed) =
cache.get_opt_job_authed(workspace_id.clone(), &token).await
{
let authed = &mut opt_job_authed.authed;
let path = original_uri.path();
let method = parts.method.as_str();
if authed.scopes.is_some() {
transform_old_scope_to_new_scope(authed.scopes.as_mut());
if let Err(err) = crate::scopes::check_scopes_for_route(
authed.scopes.as_deref(),
path,
method,
) {
return Err((err, parts));
}
}
if authed.read_only {
// MCP transport runs over POST (streamable HTTP / SSE handshake),
// so the middleware can't safely reject mutating methods on it —
// the MCP runner itself filters out write tools and rejects
// mutating tool calls for read-only tokens. Narrow to the actual
// transport endpoints: anything else under `/api/mcp/*` (OAuth
// approve, token exchange, client registration) must still go
// through the read-only check, otherwise a read-only token
// could approve an OAuth flow that mints a new non-read-only
// token.
let is_mcp_transport = path == "/api/mcp/gateway"
|| (path.starts_with("/api/mcp/w/")
&& (path.ends_with("/mcp")
|| path.ends_with("/sse")
|| path.ends_with("/list_tools")));
if !is_mcp_transport {
if let Err(err) = crate::scopes::check_read_only_for_route(path, method) {
return Err((err, parts));
}
}
}
parts.extensions.insert(authed.clone());
Span::current().record("username", &authed.username.as_str());
Span::current().record("email", &authed.email);
// Mirror into the per-request LogContext so exported OTEL
// LogRecords carry the same identifiers (the log bridge
// doesn't walk span fields — see windmill_common::log_context).
let username_copy = authed.username.clone();
let email_copy = authed.email.clone();
let workspace_copy = workspace_id.clone();
windmill_common::log_context::update_log_context(move |c| {
windmill_common::log_context::LogContext {
username: Some(username_copy),
email: Some(email_copy),
workspace_id: workspace_copy.or_else(|| c.workspace_id.clone()),
..c.clone()
}
});
if let Some(workspace_id) = workspace_id {
Span::current().record("workspace_id", &workspace_id);
}
return Ok((opt_job_authed, parts));
}
}
}
BRUTE_FORCE_COUNTER.increment().await;
Err((Error::NotAuthorized("Unauthorized".to_string()), parts))
}
/// Returns the override and whether it names the token's *label* rather than the entity that
/// fired the request. Callers must not re-derive the second element from the first: the
/// `ephemeral-script-end-user-` arm forwards a `created_by` verbatim, and `created_by` is
/// unconstrained, so it may itself look like any of these shapes.
///
/// Only namespaces `create_token` rejects (`is_server_minted_label`) are trusted to name the
/// entity acting, so the label can only have come from a server-side mint. Tokens minted
/// before that guard existed are the remaining hole; closing it needs the token row to record
/// who minted it rather than inferring it from the label.
///
/// Note that a trigger whose identity is set server-side — the SMTP one builds an `email-*`
/// override directly — does not rely on this at all, so its prefix must not be trusted here.
pub(crate) fn username_override_from_label(label: Option<String>) -> (Option<String>, bool) {
match label {
Some(label) if label.starts_with("ephemeral-webhook-") => (Some(label), false),
Some(label) if label.starts_with("ephemeral-script-end-user-") => (
Some(
label
.trim_start_matches("ephemeral-script-end-user-")
.to_string(),
),
false,
),
// User-mintable, so they name nobody in particular — the trigger panels merely
// pre-fill `webhook-`/`http-`, and the editor mints the lsp one. The override keeps
// its value because `require_job_read_access` matches it against the `created_by` of
// jobs launched under it, which these shapes produced while they were trusted.
Some(label) if label == "Ephemeral lsp token" => (Some("lsp".to_string()), true),
Some(label)
if label.starts_with("webhook-")
|| label.starts_with("http-")
|| label.starts_with("email-")
|| label.starts_with("ws-") =>
{
(Some(label), true)
}
Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => (
Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)),
true,
),
_ => (None, false),
}
}
#[derive(FromRow, Serialize)]
pub struct TruncatedTokenWithEmail {
pub label: Option<String>,
pub token_prefix: String,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub email: Option<String>,
}
pub async fn list_tokens_internal(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
) -> JsonResult<Vec<TruncatedTokenWithEmail>> {
let tokens = if is_flow {
sqlx::query_as!(
TruncatedTokenWithEmail,
r#"
SELECT label,
token_prefix,
expiration,
created_at,
last_used_at,
scopes,
email
FROM token
WHERE workspace_id = $1
AND (
scopes @> ARRAY['jobs:run:flows:' || $2]::text[]
OR scopes @> ARRAY['run:flow/' || $2]::text[]
)
"#,
w_id,
path
)
.fetch_all(db)
.await?
} else {
sqlx::query_as!(
TruncatedTokenWithEmail,
r#"
SELECT label,
token_prefix,
expiration,
created_at,
last_used_at,
scopes,
email
FROM token
WHERE workspace_id = $1
AND (
scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]
OR scopes @> ARRAY['run:script/' || $2]::text[]
)
"#,
w_id,
path
)
.fetch_all(db)
.await?
};
Ok(Json(tokens))
}