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

Extends the job-token cap with three pieces:

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-16 17:20:29 +02:00
co-authored by Claude Opus 4.8
parent 0b62ad31c0
commit 01ddbff6f6
17 changed files with 473 additions and 46 deletions
+24
View File
@@ -591,6 +591,19 @@ 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(
None,
nf.on_behalf_of_email.as_deref(),
)?;
}
let mut tx = user_db.clone().begin(&authed).await?;
check_path_conflict(&mut tx, &w_id, &nf.path).await?;
@@ -1031,6 +1044,17 @@ 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(
None,
nf.on_behalf_of_email.as_deref(),
)?;
}
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
let mut tx = user_db.clone().begin(&authed).await?;