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
parent 4f65187f9e
commit 19eaf3e2cf
27 changed files with 663 additions and 143 deletions
+1 -1
View File
@@ -1 +1 @@
2ba6a2a75b6fc97858b306b2c98ada481e363c10
39a5c579808a810438be69529714d6a2fc7da9d6
+163
View File
@@ -2810,3 +2810,166 @@ async fn test_schedule_permissions_superadmin_not_in_workspace(
Ok(())
}
// ============================================================================
// Forged-superadmin on_behalf_of guard (GHSA-hfh4-cx4h-3fcr)
// ============================================================================
/// A `wm_deployers` member must not be able to deploy a runnable whose preserved
/// on_behalf_of resolves to a superadmin identity it does not genuinely name:
/// the reserved internal sentinels, or a superadmin's email pinned onto an
/// unrelated principal. Deploying on behalf of a *consistently named* real user —
/// even a real superadmin, e.g. a git-sync round-trip of superadmin-authored
/// content — stays allowed; that is the intended deployer capability.
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_reject_forged_superadmin_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: deployer cannot pin a real superadmin's email onto an unrelated principal.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
)
.json(&new_app_with_on_behalf_of(
"u/deployer-user/app_mismatch_sa",
Some("u/original-user"),
Some(REAL_SA),
true,
))
.send()
.await?;
assert_eq!(
resp.status(),
400,
"deployer must not pin a superadmin email onto an unrelated principal: {}",
resp.text().await?
);
// App: a consistently named real superadmin identity is allowed (deployer feature).
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(())
}
@@ -175,6 +175,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
username_override: None,
token_prefix: None,
read_only: false,
token_is_job: false,
}
}
@@ -172,6 +172,67 @@ async fn test_wm_token_cannot_manage_superadmin_users(db: Pool<Postgres>) -> any
resp.text().await?
);
// 4f. Cannot read the full user directory. This route is only guarded by
// `require_super_admin` (no per-route job-token denylist), so it proves
// the cap is baked into `require_super_admin` itself — the advisory PoC.
let resp = authed(client().get(format!("{base}/list_as_super_admin")), &sa_wm)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not list all users: {}",
resp.text().await?
);
// 4g. Cannot read instance settings (unredacted SMTP/OAuth secrets, license
// key, SSO config). Also guarded only by `require_super_admin`.
let settings_base = format!("http://localhost:{port}/api/settings");
let resp = authed(
client().get(format!("{settings_base}/global/license_key")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not read instance settings: {}",
resp.text().await?
);
// 4h. No false positive: a real superadmin API token still reads the
// directory (the cap keys off the job token, not the identity).
let resp = authed(
client().get(format!("{base}/list_as_super_admin")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a real superadmin token must still list users: {}",
resp.text().await?
);
// 4i. The instance-level `devops` role is also capped: a superadmin (hence
// devops) WM_TOKEN must not reach a require_devops_role route.
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/service_logs/list_files"
)),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
401,
"superadmin WM_TOKEN must not reach a devops route: {}",
resp.text().await?
);
// 5. Escape hatch / no false positive: a real superadmin API token
// (SECRET_TOKEN, no job_id) can still create tokens.
let resp = authed(
+10
View File
@@ -207,6 +207,9 @@ impl AuthCache {
username_override,
token_prefix: claims.audit_span,
read_only: false,
// A `job_id` claim marks the WM_TOKEN of a running
// job, whose authority is capped at workspace admin.
token_is_job: claims.job_id.is_some(),
};
let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok());
AUTH_CACHE.insert(
@@ -304,6 +307,7 @@ impl AuthCache {
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
token_is_job: false,
})
} else {
tracing::warn!(
@@ -354,6 +358,7 @@ impl AuthCache {
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
token_is_job: false,
})
} else {
tracing::warn!(
@@ -425,6 +430,7 @@ impl AuthCache {
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
token_is_job: false,
})
}
None if super_admin => {
@@ -446,6 +452,7 @@ impl AuthCache {
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
token_is_job: false,
}),
Err(e) => {
tracing::error!(
@@ -469,6 +476,7 @@ impl AuthCache {
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
token_is_job: false,
})
}
}
@@ -504,6 +512,7 @@ impl AuthCache {
username_override: None,
token_prefix: Some(safe_token_prefix(token)),
read_only: false,
token_is_job: false,
};
Some(OptJobAuthed { authed, job_id: None })
} else {
@@ -711,6 +720,7 @@ fn no_auth_admin_authed() -> ApiAuthed {
username_override: None,
token_prefix: None,
read_only: false,
token_is_job: false,
}
}
+66 -13
View File
@@ -56,6 +56,12 @@ pub struct ApiAuthed {
pub username_override: Option<String>,
pub token_prefix: Option<String>,
pub read_only: bool,
/// True when this authed comes from a job token (`$WM_TOKEN`, a JWT carrying
/// a `job_id`). A job token's identity is derived from an app/flow/schedule
/// `on_behalf_of`, which a non-admin `wm_deployers` member can point at a
/// superadmin, so its authority is capped at workspace admin:
/// [`require_super_admin`] denies it even when the identity is a superadmin.
pub token_is_job: bool,
}
impl ApiAuthed {
@@ -105,6 +111,8 @@ impl From<Authed> for ApiAuthed {
username_override: None,
token_prefix: value.token_prefix,
read_only: false,
// A plain `Authed` (on-behalf/internal) carries no token context.
token_is_job: false,
}
}
}
@@ -193,15 +201,46 @@ impl windmill_mcp::server::McpAuth for ApiAuthed {
// ------------ Utility functions ------------
pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
let is_admin = is_super_admin_email(db, email).await?;
/// Require the caller to be a super admin. A super admin acting through a job
/// token (`$WM_TOKEN`) is capped at workspace admin and rejected here — see
/// [`ApiAuthed::token_is_job`] — so no `require_super_admin` endpoint (instance
/// settings, the user directory, ...) is reachable by a job token whose run-as
/// identity a `wm_deployers` member pointed at a superadmin. A genuine super
/// admin who needs such an operation from a script uses a dedicated superadmin
/// API token (which only a real super admin can create) instead of `$WM_TOKEN`.
pub async fn require_super_admin(db: &DB, authed: &ApiAuthed) -> error::Result<()> {
if authed.token_is_job && is_super_admin_email(db, &authed.email).await? {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN): a job token runs as its \
app/flow on_behalf_of identity and is capped at workspace admin. If a script \
genuinely needs a superadmin operation, 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
}
if !is_admin {
/// The caller's *effective* super admin status: a real super admin, but not
/// when acting through a job token (`$WM_TOKEN`), which is capped at workspace
/// admin (see [`ApiAuthed::token_is_job`]). Use this for inline capability gates
/// the same way [`require_super_admin`] is used for hard gates, so a job token
/// can never exceed workspace admin on any route.
pub async fn is_super_admin(db: &DB, authed: &ApiAuthed) -> error::Result<bool> {
Ok(!authed.token_is_job && is_super_admin_email(db, &authed.email).await?)
}
/// Email-only super admin check for internal (non-request) callers that have no
/// token context — background tasks, sync jobs, ... A request handler must use
/// [`require_super_admin`] instead so the job-token cap is applied.
pub async fn require_super_admin_email(db: &DB, email: &str) -> error::Result<()> {
if is_super_admin_email(db, email).await? {
Ok(())
} else {
Err(Error::NotAuthorized(
"This endpoint requires the caller to be a super admin".to_owned(),
))
} else {
Ok(())
}
}
@@ -502,16 +541,29 @@ pub fn build_scope_path_predicate(
}
}
pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> {
let is_devops = is_devops_email(db, email).await?;
if is_devops {
Ok(())
} else {
Err(Error::NotAuthorized(
/// Require the caller to have the instance-level `devops` role. Like
/// [`require_super_admin`], a caller acting through a job token (`$WM_TOKEN`) is
/// capped at workspace admin and rejected — the `devops` role is instance-level,
/// so it must not be reachable by a job token whose on_behalf_of identity a
/// `wm_deployers` member controls (`is_devops_email` also returns true for
/// superadmins). A genuine devops user who needs this from a script uses a
/// dedicated token instead of `$WM_TOKEN`.
pub async fn require_devops_role(db: &DB, authed: &ApiAuthed) -> error::Result<()> {
if !is_devops_email(db, &authed.email).await? {
return Err(Error::NotAuthorized(
"This endpoint requires the caller to have the `devops` role".to_string(),
))
));
}
if authed.token_is_job {
return Err(Error::NotAuthorized(
"This endpoint cannot be called with a job token ($WM_TOKEN): a job token is capped \
at workspace admin. If a script genuinely needs a devops operation, create a \
dedicated token from the User settings drawer, store it as a secret, and use that \
token explicitly instead of $WM_TOKEN."
.to_owned(),
));
}
Ok(())
}
// ------------ Folder ownership checks ------------
@@ -762,6 +814,7 @@ pub async fn fetch_api_authed_from_permissioned_as(
username_override: None,
token_prefix: authed.token_prefix,
read_only: false,
token_is_job: false,
};
API_AUTHED_CACHE.insert(
+7 -7
View File
@@ -117,7 +117,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 +133,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 +212,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 +280,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 +317,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 +342,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 +374,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)
+30
View File
@@ -591,6 +591,22 @@ async fn create_flow(
}
}
// Reject a forged superadmin run identity: only a preserved value is
// caller-controlled (otherwise `resolve_on_behalf_of_email` stores the
// deployer's own email below). A flow stores no permissioned_as, so this is
// the sentinel guard.
if nf.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
{
windmill_common::auth::validate_on_behalf_of(
&db,
&w_id,
None,
nf.on_behalf_of_email.as_deref(),
)
.await?;
}
let mut tx = user_db.clone().begin(&authed).await?;
check_path_conflict(&mut tx, &w_id, &nf.path).await?;
@@ -1031,6 +1047,20 @@ async fn update_flow(
validate_flow(&nf).await?;
// Reject a forged superadmin run identity in a preserved value (otherwise
// `resolve_on_behalf_of_email` stores the deployer's own email below).
if nf.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
{
windmill_common::auth::validate_on_behalf_of(
&db,
&w_id,
None,
nf.on_behalf_of_email.as_deref(),
)
.await?;
}
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
let mut tx = user_db.clone().begin(&authed).await?;
+7 -7
View File
@@ -298,7 +298,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);
@@ -463,7 +463,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")
@@ -518,7 +518,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?;
// Fetch group's instance_role and members before deletion
@@ -818,7 +818,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?;
@@ -1096,7 +1096,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?;
let group_opt = sqlx::query_scalar!("SELECT name FROM instance_group WHERE name = $1", name,)
@@ -1225,7 +1225,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,
@@ -1261,7 +1261,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?;
sqlx::query!("DELETE FROM email_to_igroup")
@@ -52,6 +52,7 @@ fn test_authed() -> ApiAuthed {
username_override: None,
token_prefix: None,
read_only: false,
token_is_job: false,
}
}
+21 -1
View File
@@ -306,6 +306,16 @@ 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(
&db,
&w_id,
Some(&resolved_permissioned_as),
Some(&resolved_email),
)
.await?;
let schedule = sqlx::query_as!(
Schedule,
r#"
@@ -518,6 +528,16 @@ 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(
&db,
&w_id,
Some(&resolved_permissioned_as),
Some(&resolved_email),
)
.await?;
let schedule = sqlx::query_as!(
Schedule,
r#"
@@ -1310,7 +1330,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);
@@ -978,6 +978,20 @@ async fn create_script_internal<'c>(
}
}
}
// Reject a forged superadmin run identity in a preserved value (otherwise
// `resolve_on_behalf_of_email` stores the deployer's own email below). A
// script stores no permissioned_as, so this is the sentinel guard.
if ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
{
windmill_common::auth::validate_on_behalf_of(
&db,
&w_id,
None,
ns.on_behalf_of_email.as_deref(),
)
.await?;
}
if sqlx::query_scalar!(
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",
hash.0,
+43 -44
View File
@@ -25,7 +25,7 @@ mod log_cleanup;
#[cfg(feature = "parquet")]
mod storage_usage;
use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed};
use windmill_api_auth::{is_super_admin, require_devops_role, require_super_admin, ApiAuthed};
use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT;
use windmill_common::DB;
@@ -52,7 +52,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,
error::{self, JsonResult, Result},
@@ -232,7 +231,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?;
let smtp = test_email.smtp;
let to = test_email.to;
@@ -269,7 +268,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 = is_super_admin(&db, &authed).await?;
let restrict = !is_super_admin && *CLOUD_HOSTED;
if restrict {
validate_object_storage_test(&test_s3_bucket).await?;
@@ -569,7 +568,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?))
}
@@ -578,7 +577,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)
@@ -589,7 +588,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)
@@ -600,7 +599,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?))
}
@@ -609,7 +608,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?))
}
@@ -619,7 +618,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(),
@@ -635,7 +634,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?))
}
@@ -649,7 +648,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 {
@@ -670,7 +669,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());
@@ -696,7 +695,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
@@ -710,7 +709,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() {
@@ -769,7 +768,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
}
@@ -1093,7 +1092,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()))?;
@@ -1104,7 +1103,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()))?;
@@ -1122,7 +1121,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
@@ -1221,7 +1220,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)
@@ -1243,7 +1242,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?;
@@ -1259,7 +1258,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,
@@ -1276,7 +1275,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)",
@@ -1301,7 +1300,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,
@@ -1331,7 +1330,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",
@@ -1374,7 +1373,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,
@@ -1420,7 +1419,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(
@@ -1444,7 +1443,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
}
@@ -1460,7 +1459,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
}
@@ -1474,7 +1473,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
}
@@ -1528,7 +1527,7 @@ async fn list_custom_instance_pg_databases(
))
})?;
if is_super_admin_email(&db, &authed.email).await? {
if is_super_admin(&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.
@@ -1578,7 +1577,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?;
Ok(Json(()))
}
@@ -1616,7 +1615,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();
@@ -1731,7 +1730,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?;
@@ -1754,7 +1753,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?;
@@ -1774,7 +1773,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?;
@@ -1794,7 +1793,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?;
@@ -1811,7 +1810,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?;
@@ -1827,7 +1826,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?;
@@ -1844,7 +1843,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?;
@@ -1859,7 +1858,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())
}
@@ -1871,7 +1870,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))
}
@@ -1883,7 +1882,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))
@@ -1976,7 +1975,7 @@ async fn sync_cached_resource_types(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> 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);
+9 -9
View File
@@ -460,7 +460,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;
@@ -1431,7 +1431,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?;
@@ -1607,7 +1607,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?;
@@ -1904,7 +1904,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?;
@@ -2219,7 +2219,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() {
@@ -2673,11 +2673,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",
@@ -2747,7 +2747,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!(
@@ -2787,7 +2787,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
@@ -102,7 +102,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![]));
}
@@ -158,7 +158,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 {
// Filter to only tags visible in this workspace
@@ -212,7 +212,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![]));
}
@@ -249,7 +249,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,
@@ -274,7 +274,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))
}
@@ -283,7 +283,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))
}
@@ -308,7 +308,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
@@ -680,7 +680,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, 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,
})
}
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use windmill_api_auth::{require_super_admin, ApiAuthed};
use windmill_api_auth::{is_super_admin, require_super_admin, ApiAuthed};
use windmill_common::DB;
use crate::workspaces::{
@@ -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 && !is_super_admin(&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)?;
}
@@ -811,7 +810,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?
&& !is_super_admin(&db, &authed).await?
{
return Err(Error::PermissionDenied(
"Deleting this workspace requires being the fork's owner or a superadmin".to_string(),
@@ -1157,7 +1156,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?
&& !is_super_admin(&db, &authed).await?
{
return Err(Error::PermissionDenied(
"Dropping forked datatable databases requires being the fork's owner or a superadmin"
@@ -1314,7 +1313,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?
&& !is_super_admin(&db, &authed).await?
{
return Err(Error::PermissionDenied(
"Dropping forked ducklake namespaces requires being the fork's owner or a superadmin"
@@ -1819,7 +1818,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 && !is_super_admin(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)"
)));
+42
View File
@@ -1934,6 +1934,17 @@ 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(
&db,
w_id,
app.policy.on_behalf_of.as_deref(),
app.policy.on_behalf_of_email.as_deref(),
)
.await?;
let mut tx = user_db.clone().begin(&authed).await?;
let path = app.path.clone();
if &app.path == "" {
@@ -2445,6 +2456,25 @@ 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(
&db,
w_id,
npolicy.on_behalf_of.as_deref(),
npolicy.on_behalf_of_email.as_deref(),
)
.await?;
}
}
let mut tx = user_db.clone().begin(&authed).await?;
let mut preserved_on_behalf_of: Option<String> = None;
@@ -4278,6 +4308,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 -5
View File
@@ -26,7 +26,7 @@ use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use url::Url;
#[cfg(all(feature = "enterprise", feature = "smtp"))]
use windmill_common::auth::is_super_admin_email;
use windmill_api_auth::is_super_admin;
use windmill_common::auth::TOKEN_PREFIX_LEN;
#[cfg(feature = "run_inline")]
use windmill_common::client::AuthedClient;
@@ -1875,7 +1875,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 && !is_super_admin(&db, &authed).await? {
return Err(Error::NotAuthorized(
"Only super admin or whitelisted token can access email workspace error handler feature"
.to_string(),
@@ -7195,7 +7195,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,
@@ -9129,11 +9129,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!(
+2 -2
View File
@@ -459,7 +459,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",
@@ -488,7 +488,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()));
+3 -3
View File
@@ -115,7 +115,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,
@@ -159,7 +159,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 +176,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?;
+103 -1
View File
@@ -301,6 +301,75 @@ 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. The stored identity is trusted verbatim at execution and decides
/// `is_super_admin` (see [`fetch_authed_from_permissioned_as_inner`]), so a
/// deploy must not be able to forge a superadmin run identity.
///
/// Enforces two invariants that no legitimate deploy can violate, so this does
/// *not* restrict the intended `wm_deployers` capability of deploying on behalf
/// of a real user — including a real superadmin, e.g. git-sync of
/// superadmin-authored content where `permissioned_as`/`email` name that user
/// consistently:
/// 1. The identity is not a reserved internal sentinel (above).
/// 2. If `on_behalf_of_email` resolves to a superadmin, it must genuinely belong
/// to `permissioned_as` — a superadmin's email cannot be pinned onto an
/// unrelated principal. Only apps store both fields; flows/scripts store just
/// the email (deriving `permissioned_as` from the deployer at execution) and
/// schedules/triggers store just `permissioned_as` (deriving the email from
/// it), so for those this second check is a no-op and the sentinel guard is
/// what applies.
pub async fn validate_on_behalf_of(
db: &DB,
w_id: &str,
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(),
));
}
if let (Some(permissioned_as), Some(email)) = (permissioned_as, on_behalf_of_email) {
// `email` is already non-sentinel here, so this only matches a real
// `password.super_admin` user.
if is_super_admin_email(db, email).await? {
let resolved =
crate::users::get_email_from_permissioned_as(permissioned_as, w_id, db).await?;
if resolved != email {
return Err(Error::BadRequest(format!(
"on_behalf_of_email '{email}' does not belong to on_behalf_of '{permissioned_as}'"
)));
}
}
}
Ok(())
}
pub async fn is_devops_email(db: &DB, email: &str) -> Result<bool> {
if is_super_admin_email(db, email).await? {
return Ok(true);
@@ -678,7 +747,40 @@ pub mod aws {
#[cfg(test)]
mod tests {
use super::is_user_token;
use super::{is_reserved_on_behalf_of_identity, is_user_token};
use crate::users::{
SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL,
};
#[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() {
+2 -2
View File
@@ -11,7 +11,7 @@ use std::net::IpAddr;
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::workspaces::{check_deploy_rules, RuleCheckResult};
@@ -695,7 +695,7 @@ 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?;
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)
+23
View File
@@ -520,6 +520,17 @@ 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(
&db,
&workspace_id,
Some(&resolved_permissioned_as),
None,
)
.await?;
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),
@@ -754,6 +765,18 @@ 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(
&db,
&workspace_id,
Some(&resolved_permissioned_as),
None,
)
.await?;
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),