fix: bound what a builder-authored app can invoke at run time

Round 2 caught that the app-side check was bound to the wrong surface. It
checked the policy triggerables, on the premise that they are what
`execute_component` resolves. That holds for `rawscript/<sha>`, which is why the
raw-script refusal binds, but not for `script/`/`flow/`: in `ExecutionMode::Viewer`
run mode falls back to a default triggerable for any such path, so a path absent
from the map is invocable rather than forbidden, and the job runs as the viewer.
A builder could therefore ship an app pointing at a runnable nobody granted them,
and an admin who merely opened it would run it as themselves.

- `ExecutionMode::Viewer` is refused for a builder-authored app. `Publisher` and
  `Anonymous` have no fallback, so the triggerables are exhaustive for them and
  the deploy-time checks below are an authorization boundary rather than advice.
- The value's `runnableByPath` entries are authorized too. They are a separate
  surface from the policy: the deployed bundle resolves a `runnable_id` against
  the stored `runnables` and sends that path, so an app with an empty
  triggerables map still reaches one.

Also from round 2: `check_flow_is_composition_only`'s doc now states what it
actually returns, and the flow-side refs are deduped, so a flow stepping through
one script thirty times stops issuing thirty round trips on every write, preview
and dependency job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-17 14:00:17 +00:00
co-authored by Claude Opus 5
parent b92ec26e0b
commit 74f15e0e51
6 changed files with 186 additions and 19 deletions
+6
View File
@@ -595,6 +595,12 @@ pub async fn validate_operator_composed_flow(
if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() {
return Ok(());
}
// A flow can step through the same script thirty times; this runs on every write, preview and
// dependency job.
refs.runnables.sort();
refs.runnables.dedup();
refs.pinned_scripts.sort_by_key(|(path, hash)| (path.clone(), hash.0));
refs.pinned_scripts.dedup_by_key(|(path, hash)| (path.clone(), hash.0));
// Composing a runnable is enough to run it: the worker resolves a step's path with the root DB
// handle and adopts that runnable's `on_behalf_of`, so an unreadable path would let a builder
// execute code it cannot see, as whoever that code runs as. RLS on this transaction is the
@@ -257,6 +257,75 @@ async fn test_operator_builder_rights_boundary(db: Pool<Postgres>) -> anyhow::Re
resp.text().await?
);
// A full-code app is deployed multipart, so the endpoint is exercised the way the editor and
// the CLI use it. The value's `runnableByPath` entries are a separate surface from the policy
// triggerables: the empty triggerables map is what distinguishes checking the value from
// re-checking the same keys twice.
let raw_app = |path: &str, mode: &str, runnable_path: &str| {
reqwest::multipart::Form::new()
.part(
"app",
reqwest::multipart::Part::text(
json!({
"path": path,
"summary": "",
"value": {"files": {}, "runnables": {"r": {
"name": "r", "type": "runnableByPath", "runType": "script",
"path": runnable_path
}}},
"policy": {"execution_mode": mode, "triggerables_v2": {}}
})
.to_string(),
)
.mime_str("application/json")
.unwrap(),
)
.part(
"js",
reqwest::multipart::Part::text("console.log(1)").file_name("app.js"),
)
};
let resp = c
.post(format!("{api}/apps/create_raw"))
.multipart(raw_app("u/operator/a3", "publisher", "u/alice/private"))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not deploy an app referencing a runnable it cannot read"
);
let resp = c
.post(format!("{api}/apps/create_raw"))
.multipart(raw_app("u/operator/a4", "viewer", "u/operator/some_script"))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not deploy a viewer-mode app: the policy stops bounding what it can invoke"
);
let resp = c
.post(format!("{api}/apps/create_raw"))
.multipart(raw_app("u/operator/a5", "publisher", "u/operator/some_script"))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to deploy a full-code app over readable runnables: {}",
resp.text().await?
);
let sandbox: Option<bool> = sqlx::query_scalar(
"SELECT (policy->>'sandbox')::boolean FROM app WHERE workspace_id = $1 AND path = $2",
)
.bind(WS)
.bind("u/operator/a5")
.fetch_one(&db)
.await?;
assert_eq!(
sandbox,
Some(true),
"a builder-authored app must be stored sandboxed"
);
invalidate_operator_builder_cache(WS);
Ok(())
}
+60 -13
View File
@@ -56,7 +56,10 @@ use std::str;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
apps::{app_value_has_inline_script, AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
apps::{
app_value_has_inline_script, app_value_runnable_paths, AppScriptId, ListAppQuery,
APP_WORKSPACED_ROUTE,
},
auth::TOKEN_PREFIX_LEN,
cache::{self, future::FutureCachedExt},
db::{DbWithOptAuthed, UserDB},
@@ -1915,6 +1918,7 @@ fn check_operator_composed_app(
"Operators with builder rights can only author full-code apps, and cannot convert an existing app into one".to_string(),
));
}
let mut referenced: Vec<(bool, String)> = Vec::new();
if let Some(value) = value {
let value: serde_json::Value = serde_json::from_str(value.get()).map_err(to_anyhow)?;
if app_value_has_inline_script(&value) {
@@ -1923,6 +1927,7 @@ fn check_operator_composed_app(
.to_string(),
));
}
referenced.extend(app_value_runnable_paths(&value));
}
let Some(policy) = policy else {
return Err(Error::BadRequest(
@@ -1952,20 +1957,33 @@ fn check_operator_composed_app(
}
policy.sandbox = Some(true);
// In `Viewer` mode `execute_component` falls back to a default triggerable for any
// `script/`/`flow/` path, so the policy stops being the list of what the app may invoke, and
// the job runs as the *viewer*. A builder-authored app would then let an admin who merely
// opens it run anything in the workspace as themselves. `Publisher` and `Anonymous` have no
// such fallback, so the triggerables checked below are exhaustive for them.
if matches!(policy.execution_mode, ExecutionMode::Viewer) {
return Err(Error::NotAuthorized(
"Operators with builder rights cannot deploy an app that runs as its viewer. Deploy it on behalf of yourself instead."
.to_string(),
));
}
// The triggerables are the deployed app's authorization to invoke a runnable.
// `<component>:` prefixes the key when the app scopes it to one component.
let mut referenced = policy
.triggerables
.iter()
.flat_map(|t| t.keys())
.chain(policy.triggerables_v2.iter().flat_map(|t| t.keys()))
.filter_map(|key| {
let key = key.split_once(':').map_or(key.as_str(), |(_, rest)| rest);
key.strip_prefix("script/")
.map(|p| (false, p.to_string()))
.or_else(|| key.strip_prefix("flow/").map(|p| (true, p.to_string())))
})
.collect::<Vec<_>>();
referenced.extend(
policy
.triggerables
.iter()
.flat_map(|t| t.keys())
.chain(policy.triggerables_v2.iter().flat_map(|t| t.keys()))
.filter_map(|key| {
let key = key.split_once(':').map_or(key.as_str(), |(_, rest)| rest);
key.strip_prefix("script/")
.map(|p| (false, p.to_string()))
.or_else(|| key.strip_prefix("flow/").map(|p| (true, p.to_string())))
}),
);
referenced.sort();
referenced.dedup();
for (_, path) in &referenced {
@@ -5703,6 +5721,12 @@ mod operator_app_tests {
.unwrap()
}
fn path_runnable(path: &str, run_type: &str) -> serde_json::Value {
serde_json::json!({"files": {}, "runnables": {
"a": {"name": "a", "type": "runnableByPath", "path": path, "runType": run_type}
}})
}
fn composed_app(
value: serde_json::Value,
policy: &mut Policy,
@@ -5752,6 +5776,29 @@ mod operator_app_tests {
);
}
// What the deployed bundle actually asks to run comes from the value's `runnableByPath`
// entries, which are a separate surface from the policy: both are reported.
let mut policy = builder_policy(serde_json::json!({}));
assert_eq!(
composed_app(path_runnable("f/x/s", "script"), &mut policy).unwrap(),
vec![(false, "f/x/s".to_string())]
);
let mut policy = builder_policy(serde_json::json!({}));
assert_eq!(
composed_app(path_runnable("f/x/f", "flow"), &mut policy).unwrap(),
vec![(true, "f/x/f".to_string())]
);
let mut policy = builder_policy(serde_json::json!({}));
assert!(composed_app(path_runnable("hub/1/x", "hubscript"), &mut policy).is_err());
// `Viewer` mode makes `execute_component` accept any script/flow path, triggerables or
// not, and run it as the viewer, so the checks above would stop binding.
let mut policy: Policy = serde_json::from_value(serde_json::json!({
"execution_mode": "viewer", "triggerables_v2": {}
}))
.unwrap();
assert!(composed_app(clean.clone(), &mut policy).is_err());
// Low-code apps and kind conversion stay closed.
let value = to_raw_value(&clean);
let mut policy = builder_policy(serde_json::json!({}));
+33
View File
@@ -34,6 +34,39 @@ pub fn app_value_has_inline_script(value: &Value) -> bool {
}
}
/// Every workspace runnable the app value points a component at, as `(is_flow, path)`.
///
/// This is what the deployed bundle actually asks `execute_component` to run: it resolves a
/// `runnable_id` against the stored `runnables` and sends the referenced path. The policy's
/// triggerables are a separate surface, so both have to be authorized.
pub fn app_value_runnable_paths(value: &Value) -> Vec<(bool, String)> {
fn walk(value: &Value, out: &mut Vec<(bool, String)>) {
match value {
Value::Object(object) => {
let by_path = object
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| t == "runnableByPath" || t == "path");
if by_path {
if let Some(path) = object.get("path").and_then(Value::as_str) {
let is_flow =
object.get("runType").and_then(Value::as_str) == Some("flow");
out.push((is_flow, path.to_string()));
}
}
for value in object.values() {
walk(value, out);
}
}
Value::Array(array) => array.iter().for_each(|v| walk(v, out)),
_ => {}
}
}
let mut out = Vec::new();
walk(value, &mut out);
out
}
/// Traverse FlowValue while invoking provided by caller callback on leafs
// #[async_recursion::async_recursion(?Send)]
pub fn traverse_app_inline_scripts<
+3 -2
View File
@@ -252,8 +252,9 @@ pub async fn resolve_modules(
/// modules, every branch, and the `tools` of an AI agent step.
///
/// Returns what the caller still has to authorize against its own permissions, which this
/// value-only walk cannot: the worker tags the steps pin (a tag is how a step picks the worker
/// group it runs on) and the `(path, hash)` pairs of version-pinned script steps.
/// value-only walk cannot: every runnable the steps reference, the worker tags they pin, and the
/// `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not
/// already settled by the walk.
pub fn check_flow_is_composition_only(value: &FlowValue) -> Result<ComposedFlowRefs, Error> {
let mut refs = ComposedFlowRefs::default();
for module in value
+15 -4
View File
@@ -40,10 +40,21 @@ permissions:
that hash alone, with the path beside it never consulted, so a readable path paired with another
script's hash still runs that other script.
The same reasoning applies to a builder-authored app: its policy triggerables are what
`execute_component` will resolve, also with the root DB handle, so
`validate_operator_composed_app` checks every `script/<path>` and `flow/<path>` key the same way
and refuses hub ones. `execute_component`'s preview branch refuses a hub path for operators too:
The same reasoning applies to a builder-authored app, with one extra step. `execute_component`
resolves the runnable it runs on the root handle, so `validate_operator_composed_app` checks every
referenced path under the caller's RLS and refuses hub ones. But it has to check **two** surfaces,
because they are not the same list: the policy's `script/<path>` and `flow/<path>` triggerables,
and the `runnableByPath` entries in the app value, which is what the deployed bundle resolves a
`runnable_id` against and sends.
What makes those checks bind is that **`ExecutionMode::Viewer` is refused for a builder app**. In
Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/`
path, so the policy stops being the list of what the app may invoke, and the job runs as the
*viewer*: an admin who merely opened the app would run anything in the workspace as themselves.
`Publisher` and `Anonymous` have no such fallback. If you ever relax the Viewer refusal, the
deploy-time path checks above stop being an authorization boundary.
`execute_component`'s preview branch refuses a hub path for operators too:
`require_path_read_access_for_preview` admits `hub/` for everyone.
Call it on every write **and** every preview: `run_preview_flow_job` and