mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
* fix: confine job tokens to workspace-scoped API routes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the object-storage connection test reachable from a job token Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the workspace-exists check the CLI makes reachable from a job Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: reconcile the job-token caps after #10124 The workspace-confinement middleware answers a workspace-less route before the privilege gate behind it runs, so the cases #10124 added on those routes now see 403 rather than 401. Rejection is what they assert, but two of them needed more than a status change: - `list_worker_groups` asserted only that the response body omits the static env value, which an error body satisfies for the wrong reason. It now asserts the status, keeping the secret check as a second assertion. - `require_super_admin` lost its only unshadowed route. `GET /api/w/{workspace}/users/list_addable` is gated solely by that call and names a workspace, so it reaches the gate and pins it at 401. The module doc states the two-layer rule once; the file covers both caps, so it is no longer named for either one alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: correct the workspaces/exists rationale and the parquet gate note `workspace` carries no row-level security, so `exists_workspace` running through `user_db` does not filter by membership as the comment claimed. State what the route actually discloses — whether a workspace id is taken. The object-storage case explained why a 404 would satisfy the assertion for the wrong reason, which described the earlier `assert_ne!(403)`; against the 422 it now asserts, a 404 fails. Say instead why the case is gated on the feature. * fix: let a job token keep the workspace-less routes that carry no workspace Confinement refused every route outside the allowlist, including ones that answer purely from the caller's own account or from the request body. Those cross no workspace boundary, so refusing them buys nothing: - `users/email` returns a value already inside the token, and `workspaces/allowed_domain_auto_invite` tests the caller's own address against a static list. Neither opens a transaction. - `users/usage` reads the caller's own row; `users/tutorial_progress` reads and upserts a UI bitfield keyed on the same email. - `schedules/preview` takes no `ApiAuthed` at all — it computes the occurrences of the cron expression in the body and returns nothing the caller did not send. The rule, not the list, is what the doc comment states: answers from the caller's own account, the request body, or content identical for every workspace; never naming another workspace, never instance configuration. The candidates it excludes are written down with their reasons, since `users/list_invites` reads as caller-scoped until you notice the response carries a workspace id per invite. Regression covers both directions — the new entries answer, and the rejected caller-scoped reads stay refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: reach the privilege gates confinement hides `require_devops_role` and `require_instance_admin` gate only workspace-less routes, so confinement answers every request that would reach them: no HTTP case can tell whether they still cap job tokens, and both could lose that check with this suite green. `require_super_admin` has a workspace-scoped route to reach it; these two have none, so call them directly instead. The identity used is the fixture's real superadmin, so the passing half proves the rejection keys off `job_id` rather than off the user. Also separate two claims the write-allowlist doc had merged into one sentence: no entry writes outside the caller's own account, but what each may read differs, and the workspace-existence check answers for any id. * docs: say that the object-storage probe writes The write-allowlist lead claimed no entry writes state outside the caller's own account. `test_s3_bucket` puts an object into the store the body names and deletes it again, so it does write; a failure between the two leaves the object behind. The invariant that holds is about Windmill state. Say so in the lead, and describe the put/delete in the entry itself rather than leaving "acts only on the store the request body describes" to imply a read. * test: cover the last two job-token gates confinement hides Seven guards key on `ApiAuthed::job_id`. Three keep a workspace-scoped route and are exercised over HTTP; the other four are reachable only through workspace-less routes, which confinement now answers first, so nothing observed whether they still cap job tokens. `require_devops_role` and `require_instance_admin` were already called directly. Add the two that were not: `forbid_superadmin_job_token`, and `forbid_elevated_job_token`, whose call sites are `create_token`, `update_token_scopes` and `set_password` — all workspace-less. Both key on two conditions rather than one, so all three combinations are pinned: neither fires without job provenance, and neither fires for an unelevated identity. The second matters — collapsing either into a blanket job-token refusal would stop ordinary users creating tokens, and no other case would catch it. The doc comment records which of the seven each route covers. * docs: correct which job-token gates have no observable route The previous commit put `forbid_elevated_job_token` among the guards reachable only through workspace-less routes, and its message named three call sites. It has six, and two are workspaced: `mint_app_embed_token` and `mint_raw_app_sdk_token`. Its superadmin branch is therefore already exercised over HTTP — the 401 the embed-token case asserts is this gate. So three of the seven lack an observable route, not four. Its direct assertions stay: the embed-token case only ever reaches it with an elevated identity, and the unelevated-negative case is what would catch the gate being collapsed into a blanket job-token refusal. * test: pin is_instance_admin, and stop enumerating gates in prose `is_instance_admin` is `authed.is_admin && authed.job_id.is_none()`, so a census built by searching for `job_id.is_some()` could not see it. Both its call sites are workspace-less, and it returns a bool that selects obfuscation rather than refusing — a job token reading `true` leaks `env_vars_static` instead of being turned away. Pin both directions. The doc comment tried to account for every job-token guard and which route exercised it. It was wrong three times running: the count, the call sites of `forbid_elevated_job_token`, and the claim that the CUSTOM_INSTANCE_DB case covers `is_super_admin_authed` when that path tests `job_id` inline. A table that has to be rederived from six crates to stay true does not belong in a comment, so it now states only why these calls are direct. * docs: name the right is_instance_admin caller The comment credited "the concurrency-group listing" with obfuscating rows. The second caller is `prune_concurrency_group`, which returns PermissionDenied; the obfuscating one is `list_worker_groups`. Keep the claim to that single caller, which is what makes this guard fail by leaking `env_vars_static` rather than by admitting a request. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1012 lines
42 KiB
Rust
1012 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 path = original_uri.path();
|
|
let method = parts.method.as_str();
|
|
if workspace_id.is_none() && opt_job_authed.job_id.is_some() {
|
|
if let Err(err) = crate::scopes::check_job_token_for_global_route(path, method)
|
|
{
|
|
return Err((err, parts));
|
|
}
|
|
}
|
|
let authed = &mut opt_job_authed.authed;
|
|
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))
|
|
}
|