fix: cap job token authority at workspace admin

A job token ($WM_TOKEN) derives its identity from an app/flow/schedule
on_behalf_of, which a wm_deployers member controls. Ensure such a token can
never act above workspace admin, regardless of the identity it runs as.

- ApiAuthed gains token_is_job (set from the JWT job_id claim).
- require_super_admin / is_super_admin / require_devops_role now take &ApiAuthed
  and deny job tokens for superadmin/devops-gated endpoints (one choke point
  instead of a per-route denylist); require_super_admin_email kept for
  internal, non-request callers.
- Inline is_super_admin_email(authed.email) capability gates migrated to the
  cap-aware is_super_admin(db, &authed).
- Defense in depth: validate_on_behalf_of rejects reserved internal identities
  and mismatched-superadmin emails when persisting an app/flow/script/schedule/
  trigger on_behalf_of; app execution refuses reserved-identity policies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-14 16:40:10 +02:00
co-authored by Claude Opus 4.8
parent 4f65187f9e
commit 19eaf3e2cf
27 changed files with 663 additions and 143 deletions
@@ -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, is_super_admin, require_devops_role,
require_is_writer, require_super_admin, ApiAuthed,
};
use windmill_api_users::users::WorkspaceInvite;
use windmill_common::email_oss::send_email_if_possible;
@@ -2176,7 +2176,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 !is_super_admin(&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_'"
@@ -2292,7 +2292,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 !is_super_admin(&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_'"
@@ -2365,11 +2365,11 @@ async fn edit_ducklake_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, email, .. }: ApiAuthed,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
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
@@ -2449,7 +2449,7 @@ async fn edit_ducklake_config(
&new_config.settings.ducklakes,
&old_ducklakes,
&username,
&email,
&authed.email,
)
.await?;
@@ -2462,11 +2462,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?;
@@ -3585,7 +3585,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(
@@ -3739,7 +3739,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
@@ -3770,7 +3770,7 @@ async fn list_workspaces_as_super_admin(
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?;
@@ -4000,7 +4000,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"))]
@@ -5328,7 +5328,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,
@@ -5723,7 +5723,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,
@@ -6040,7 +6040,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 && !is_super_admin(&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)"
)));
@@ -6405,9 +6405,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 && !is_super_admin(&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)"
)));
@@ -8041,7 +8039,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 = is_super_admin(&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;
@@ -8139,6 +8137,8 @@ async fn load_workspace_authed(
username_override: base_authed.username_override.clone(),
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
// Same token, different workspace: a job token stays capped.
token_is_job: base_authed.token_is_job,
});
};
@@ -8168,6 +8168,8 @@ async fn load_workspace_authed(
username_override: base_authed.username_override.clone(),
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
// Same token, different workspace: a job token stays capped.
token_is_job: base_authed.token_is_job,
})
}