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
parent 0b62ad31c0
commit 01ddbff6f6
17 changed files with 473 additions and 46 deletions
@@ -978,6 +978,17 @@ 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(
None,
ns.on_behalf_of_email.as_deref(),
)?;
}
if sqlx::query_scalar!(
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",
hash.0,