feat: operator builder rights

A workspace setting that lets every operator of that workspace compose flows and
full-code apps out of runnables that already exist, without authoring code.
Operators of such a workspace consume a full seat instead of half a seat.

The operator boundary stays where it was: authoring code and running arbitrary
code. Builder rights do not move it, they only let an operator assemble what is
already deployed.

Backend:
- `builder` on `operator_settings` (no migration), read through a cached
  `operator_builder_enabled`; `check_operator_can_build` is the gate helper.
- `check_flow_is_composition_only` walks the whole flow value (modules,
  preprocessor, failure module, branches, AI agent tools) and refuses inline
  code, hoisted flow-node references, hub runnables and resource-linked AI
  agents, then returns the worker tags its steps pin so the caller can
  authorize them. Runs on every write AND every preview: `run_preview_flow_job`
  and `push_flow_dependencies_job` both take a caller-supplied flow value.
- Raw apps: `create_app_raw` / `update_app_raw` open, `*_raw_source` stays shut
  (it compiles caller-supplied sources with a bundler job on a worker).
  `check_operator_composed_app` refuses inline scripts anywhere in the app value
  and forces `policy.sandbox`, so a bundle nobody reviewed cannot borrow the
  viewer's Windmill session.
- Low-code apps, scripts, script previews and dependency jobs stay closed.

Frontend: the flow editor and raw app editor open for builders, with every
affordance that authors code removed (inline languages, hub browser, AI
builders, script edit/fork, the inline starter runnable).

Fixes a separate bug: `can_preserve_on_behalf_of` granted preservation to
anyone in `wm_deployers`, a group a group owner can grant, so an operator could
edit a runnable while keeping it pointed at its original admin author.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-16 21:06:08 +00:00
co-authored by Claude Opus 5
parent 0258f3f81b
commit 6e9e0de481
41 changed files with 1120 additions and 216 deletions
+3
View File
@@ -30,6 +30,9 @@ Open-source platform for internal tools, workflows, API integrations, background
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Operator builder rights**: `docs/operator-builder-rights.md`: the workspace setting that lets
operators compose flows and full-code apps, what the composition check must cover, and which
raw-app endpoint stays closed on purpose
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
silently, so frontend-only instrumentation records nothing.
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE((operator_settings->>'builder')::boolean, false)\n FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "coalesce",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "0d6a28270d740c4315ab20f6252b84a779cac605acf6e88e3ae0c585303be31c"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1 AND is_service_account IS false\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "bool_and",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH all_audit AS (SELECT username, operation, timestamp FROM audit_partitioned UNION ALL SELECT username, operation, timestamp FROM audit),\n active_users as (SELECT distinct username as email FROM all_audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"query": "WITH all_audit AS (SELECT username, operation, timestamp FROM audit_partitioned UNION ALL SELECT username, operation, timestamp FROM audit),\n active_users as (SELECT distinct username as email FROM all_audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct t.email FROM usr t LEFT JOIN workspace_settings ws ON ws.workspace_id = t.workspace_id WHERE NOT (t.operator AND NOT COALESCE((ws.operator_settings->>'builder')::boolean, false)) AND t.email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
null
]
},
"hash": "359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b"
"hash": "f54d8959922435013239240db3f0f058e2634f8b9cd1019e277cad0120cf5fe3"
}
+1 -1
View File
@@ -1 +1 @@
f079db9e7962a413b349c4ff8036080894f30771
9ba2f517be9f688d324bb5ad1e9379fac5fcc3ca
+52 -27
View File
@@ -16,10 +16,10 @@ use axum::{
};
use windmill_api_auth::{
auth::{list_tokens_internal, TruncatedTokenWithEmail},
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
ApiAuthed,
build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders,
require_owner_of_path, ApiAuthed,
};
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
use windmill_common::workspaces::{check_deploy_rules, check_operator_can_build, RuleCheckResult};
use windmill_common::{
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
utils::HTTP_CLIENT,
@@ -35,7 +35,7 @@ use sqlx::{FromRow, Postgres, Transaction};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind};
use windmill_common::flows::FlowModule;
use windmill_common::flows::{FlowModule, FlowValue};
use windmill_common::min_version::{
MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2,
MIN_VERSION_SUPPORTS_NODE_DEBOUNCING,
@@ -533,7 +533,12 @@ async fn list_paths_from_workspace_runnable(
Ok(Json(runnables))
}
async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
async fn validate_flow(
new_flow: &NewFlow,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
) -> error::Result<()> {
#[cfg(not(feature = "enterprise"))]
if new_flow.ws_error_handler_muted.is_some_and(|val| val) {
return Err(Error::BadRequest(
@@ -544,9 +549,45 @@ async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
guard_flow_from_debounce_data(new_flow).await?;
if authed.is_operator {
validate_operator_composed_flow(
&new_flow.parse_flow_value()?,
&new_flow.tag,
authed,
db,
w_id,
)
.await?;
}
return Ok(());
}
/// Runs on every write and every preview of a flow authored by an operator with builder rights.
/// The tags a step pins are authorized here rather than in the walk: without it a builder could
/// route a job onto a privileged worker group.
pub async fn validate_operator_composed_flow(
value: &FlowValue,
flow_tag: &Option<String>,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
) -> error::Result<()> {
let mut tags = windmill_common::flows::check_flow_is_composition_only(value)?;
tags.extend(flow_tag.clone());
for tag in tags.iter().filter(|t| !t.is_empty()) {
windmill_common::jobs::check_tag_available_for_workspace_internal(
db,
w_id,
tag,
&authed.email,
get_scope_tags(authed),
)
.await?;
}
Ok(())
}
async fn create_flow(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -555,11 +596,7 @@ async fn create_flow(
Path(w_id): Path<String>,
Json(mut nf): Json<NewFlow>,
) -> Result<(StatusCode, String)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create flows for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "create flows").await?;
check_scopes(&authed, || format!("flows:write:{}", nf.path))?;
// A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly.
@@ -579,7 +616,7 @@ async fn create_flow(
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf).await?;
validate_flow(&nf, &authed, &db, &w_id).await?;
if *CLOUD_HOSTED {
let nb_flows =
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
@@ -1110,11 +1147,7 @@ async fn update_flow(
Path((w_id, flow_path)): Path<(String, StripPath)>,
Json(ef): Json<EditFlow>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update flows for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "update flows").await?;
let flow_path = flow_path.to_path();
// The URL identifies the flow being updated; the body path is only needed to rename.
let mut nf = ef.into_new_flow(flow_path);
@@ -1141,7 +1174,7 @@ async fn update_flow(
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf).await?;
validate_flow(&nf, &authed, &db, &w_id).await?;
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
let mut tx = user_db.clone().begin(&authed).await?;
@@ -1765,11 +1798,7 @@ async fn archive_flow_by_path(
Path((w_id, path)): Path<(String, StripPath)>,
Json(archived): Json<Archived>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot archive flows for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "archive flows").await?;
let path = path.to_path();
check_scopes(&authed, || format!("flows:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
@@ -1910,11 +1939,7 @@ async fn delete_flow_by_path(
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<DeleteFlowQuery>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot delete flows for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "delete flows").await?;
let path = path.to_path();
check_scopes(&authed, || format!("flows:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
@@ -16,17 +16,17 @@ use windmill_api_auth::ApiAuthed;
use windmill_common::{
error::Error,
variables::{build_crypt, encrypt},
workspaces::invalidate_operator_builder_cache,
};
use windmill_native_triggers::{
classify_read_failure, decrypt_oauth_data, delete_native_trigger,
delete_workspace_integration, get_workspace_integration,
classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
get_workspace_integration,
github::GitHub,
google::{parse_stop_channel_params, should_renew_channel},
http_error_status, list_native_triggers, map_external_error,
grant_refused, http_error_status, list_native_triggers, map_external_error,
nextcloud::NextCloud,
grant_refused, require_native_integration_use, store_native_trigger,
store_workspace_integration, External, ExternalReadFailure, HttpRequestError,
NativeTriggerConfig, OAuthConfig, ServiceName,
require_native_integration_use, store_native_trigger, store_workspace_integration, External,
ExternalReadFailure, HttpRequestError, NativeTriggerConfig, OAuthConfig, ServiceName,
};
// ============================================================================
@@ -339,24 +339,53 @@ async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
// 3. Channel Expiration Renewal — should_renew_channel
// ============================================================================
#[test]
fn test_require_native_integration_use_blocks_operators() {
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_require_native_integration_use_blocks_operators(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
// Regression: the integration *use* routes (calendar/drive/repo/event pickers)
// must reject read-only operators, who cannot create native triggers and so
// must not be able to drive the admin-configured integration's upstream API.
// must reject read-only operators, who must not be able to drive the
// admin-configured integration's upstream API.
let w_id = "test-workspace";
// The builder flag is read through a process-global 60s cache keyed by workspace id, and
// every test in this crate shares that id.
invalidate_operator_builder_cache(w_id);
let mut operator = test_authed();
operator.is_admin = false;
operator.is_operator = true;
assert!(require_native_integration_use(&operator).is_err());
assert!(require_native_integration_use(&operator, &db, w_id)
.await
.is_err());
// A regular non-admin author (the population that configures triggers) is allowed.
let mut author = test_authed();
author.is_admin = false;
author.is_operator = false;
assert!(require_native_integration_use(&author).is_ok());
assert!(require_native_integration_use(&author, &db, w_id)
.await
.is_ok());
// Admins are allowed.
assert!(require_native_integration_use(&test_authed()).is_ok());
assert!(require_native_integration_use(&test_authed(), &db, w_id)
.await
.is_ok());
// Operators with builder rights author triggers, so they need the pickers.
sqlx::query(
"UPDATE workspace_settings SET operator_settings = '{\"builder\": true}'::jsonb
WHERE workspace_id = $1",
)
.bind(w_id)
.execute(&db)
.await?;
invalidate_operator_builder_cache(w_id);
assert!(require_native_integration_use(&operator, &db, w_id)
.await
.is_ok());
invalidate_operator_builder_cache(w_id);
Ok(())
}
#[test]
@@ -782,7 +811,10 @@ fn test_refresh_failures_blame_only_the_grant_they_refuse() {
let ok = Some(StatusCode::OK);
assert!(grant_refused(ok, r#"{"error":"bad_refresh_token"}"#));
assert!(grant_refused(ok, r#"{"error":"invalid_grant"}"#));
assert!(!grant_refused(ok, r#"{"access_token":"t","token_type":"bearer"}"#));
assert!(!grant_refused(
ok,
r#"{"access_token":"t","token_type":"bearer"}"#
));
}
/// A service that is busy or broken has not refused anything, and callers react differently to
@@ -824,7 +856,9 @@ fn test_transient_service_failures_are_not_refusals() {
body: r#"{"message":"Must have admin rights to Repository."}"#.to_string(),
});
assert!(
map_external_error(refused).to_string().contains("admin rights"),
map_external_error(refused)
.to_string()
.contains("admin rights"),
"a real 403 keeps its guidance"
);
}
@@ -0,0 +1,183 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_builder_cache;
use windmill_test_utils::*;
const WS: &str = "test-workspace";
fn operator_client() -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str("Bearer OPERATOR_TOKEN_1").unwrap(),
);
reqwest::ClientBuilder::new()
.default_headers(headers)
.build()
.unwrap()
}
async fn set_builder(db: &Pool<Postgres>, enabled: bool) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2",
)
.bind(format!(r#"{{"builder": {enabled}}}"#))
.bind(WS)
.execute(db)
.await?;
// The flag is read through a process-global 60s cache keyed by workspace id.
invalidate_operator_builder_cache(WS);
Ok(())
}
fn composition_flow(path: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "",
"description": "",
"schema": {},
"value": {"modules": [{
"id": "a",
"value": {"type": "script", "path": "u/operator/some_script", "input_transforms": {}}
}]}
})
}
fn inline_code_flow(path: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "",
"description": "",
"schema": {},
"value": {"modules": [{
"id": "a",
"value": {
"type": "rawscript",
"content": "export async function main() { return 1 }",
"language": "bun",
"input_transforms": {}
}
}]}
})
}
/// The whole boundary in one pass: builder rights let an operator compose deployed runnables and
/// nothing more, and the endpoints that author code stay shut whether or not they are granted.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_operator_builder_rights_boundary(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
let c = operator_client();
set_builder(&db, false).await?;
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow("u/operator/f1"))
.send()
.await?;
assert!(
!resp.status().is_success(),
"an operator without builder rights must not create a flow"
);
set_builder(&db, true).await?;
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow("u/operator/f1"))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to create a composition-only flow: {}",
resp.text().await?
);
let resp = c
.post(format!("{api}/flows/create"))
.json(&inline_code_flow("u/operator/f2"))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not deploy a flow carrying inline code"
);
// Same for the preview path, which runs a request-supplied flow value rather than a stored one.
let resp = c
.post(format!("{api}/jobs/run/preview_flow"))
.json(&json!({"value": inline_code_flow("u/operator/f2")["value"], "args": {}}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not preview a flow carrying inline code"
);
// Authoring code, in any of its shapes, stays shut with builder rights granted.
let resp = c
.post(format!("{api}/scripts/create"))
.json(&json!({
"path": "u/operator/s1",
"summary": "",
"description": "",
"content": "export async function main() { return 1 }",
"language": "bun",
"is_template": false
}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not create a script"
);
// `*_raw_source` compiles caller-supplied sources with a bundler job on a worker. It sits
// beside `create_app_raw`/`update_app_raw`, which builders MAY use, so it is the gate most
// likely to be opened by mistake later.
let raw_source_app = json!({
"path": "u/operator/a1",
"summary": "",
"value": {"files": {"index.ts": "console.log(1)"}, "runnables": {}},
"policy": {"execution_mode": "publisher"}
});
let resp = c
.post(format!("{api}/apps/create_raw_source"))
.json(&raw_source_app)
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not compile app sources on a worker"
);
let resp = c
.post(format!("{api}/apps/update_raw_source/u/operator/a1"))
.json(&json!({"summary": "x"}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not compile app sources on a worker"
);
// Low-code apps carry inline scripts, so they stay shut too.
let resp = c
.post(format!("{api}/apps/create"))
.json(&json!({
"path": "u/operator/a2",
"summary": "",
"value": {"grid": []},
"policy": {"execution_mode": "publisher"}
}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not create a low-code app"
);
invalidate_operator_builder_cache(WS);
Ok(())
}
@@ -8274,8 +8274,12 @@ async fn invite_user(
nu.email = nu.email.to_lowercase();
#[cfg(feature = "enterprise")]
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(
&db,
&nu.email,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?,
)
.await?
{
return Err(Error::BadRequest(msg));
}
@@ -8426,8 +8430,12 @@ async fn add_user(
};
#[cfg(feature = "enterprise")]
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(
&db,
&nu.email,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?,
)
.await?
{
return Err(Error::BadRequest(msg));
}
@@ -8990,6 +8998,11 @@ struct ChangeOperatorSettings {
folders: bool,
#[serde(default)]
workers: bool,
/// Lets every operator of this workspace compose flows and raw apps out of already-deployed
/// runnables. Unlike the visibility flags above this is a write right, and it makes each
/// operator consume a full author seat instead of half of one.
#[serde(default)]
builder: bool,
}
async fn update_operator_settings(
@@ -9000,6 +9013,17 @@ async fn update_operator_settings(
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
// Every operator of the workspace turns into a full seat, which an offline license may not
// cover. The check is a no-op delta when builder rights are already on.
#[cfg(feature = "enterprise")]
if settings.builder {
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await?
{
return Err(Error::BadRequest(msg));
}
}
let mut tx = db.begin().await?;
let settings_json = serde_json::json!(settings);
@@ -9014,6 +9038,8 @@ async fn update_operator_settings(
tx.commit().await?;
windmill_common::workspaces::invalidate_operator_builder_cache(&w_id);
// Trigger git sync for operator settings changes
handle_deployment_metadata(
&authed.email,
+3
View File
@@ -33151,6 +33151,9 @@ components:
workers:
type: boolean
description: Whether operators can view workers page
builder:
type: boolean
description: Whether operators can compose flows and raw apps out of existing runnables (consumes a full seat)
WorkspaceComparison:
type: object
+90 -24
View File
@@ -56,7 +56,7 @@ use std::str;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
apps::{traverse_app_inline_scripts, AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
auth::TOKEN_PREFIX_LEN,
cache::{self, future::FutureCachedExt},
db::{DbWithOptAuthed, UserDB},
@@ -75,7 +75,8 @@ use windmill_common::{
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
workspaces::{
check_deploy_rules, check_user_against_rule, ProtectionRuleKind, RuleCheckResult,
check_deploy_rules, check_operator_can_build, check_user_against_rule,
operator_builder_enabled, ProtectionRuleKind, RuleCheckResult,
},
HUB_BASE_URL,
};
@@ -1426,11 +1427,7 @@ async fn mint_preview_sdk_token(
Path(w_id): Path<String>,
Json(req): Json<PreviewSdkTokenRequest>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot preview raw apps".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "preview raw apps").await?;
check_scopes(&authed, || format!("apps:write:{}", req.path))?;
let (token, _expiration) =
mint_raw_app_sdk_token(&db, &w_id, &req.path, &authed, &req.scopes, job_id).await?;
@@ -1897,6 +1894,47 @@ async fn store_raw_app_file<'a>(
Ok(())
}
/// Runs on every app write by an operator with builder rights, from inside the create/update
/// internals so both raw-app endpoints are covered by one check.
///
/// A raw app's behaviour lives in a bundle the browser built, which no server-side check can read,
/// so isolation is what makes it safe to let an operator publish one: `sandbox` renders it in an
/// opaque-origin iframe instead of handing it the viewer's Windmill session. Inline scripts are
/// the low-code side's way of carrying code and must not appear either.
fn check_operator_composed_app(
raw_app: bool,
value: Option<&RawValue>,
policy: Option<&mut Policy>,
allow_kind_change: bool,
) -> Result<()> {
if !raw_app || allow_kind_change {
return Err(Error::NotAuthorized(
"Operators with builder rights can only author full-code apps, and cannot convert an existing app into one".to_string(),
));
}
if let Some(value) = value {
let value: serde_json::Value = serde_json::from_str(value.get()).map_err(to_anyhow)?;
traverse_app_inline_scripts(&value, None, &mut |_, _| {
Err(Error::NotAuthorized(
"Operators with builder rights cannot deploy an app carrying inline scripts"
.to_string(),
))
})?;
}
let Some(policy) = policy else {
return Err(Error::BadRequest(
"Operators with builder rights must deploy an app with its policy".to_string(),
));
};
if policy.sandbox == Some(false) {
return Err(Error::NotAuthorized(
"Operators with builder rights can only deploy sandboxed apps".to_string(),
));
}
policy.sandbox = Some(true);
Ok(())
}
macro_rules! process_app_multipart {
($authed:expr, $user_db:expr, $db:expr, $w_id:expr, $path:expr, $multipart:expr, $internal_fn:expr) => {
async {
@@ -1973,11 +2011,7 @@ async fn create_app_raw<'a>(
Path(w_id): Path<String>,
multipart: Multipart,
) -> Result<(StatusCode, String)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create apps for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "create apps").await?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
@@ -2112,6 +2146,9 @@ async fn create_app_internal<'a>(
// denied app committed in the DB.
check_scopes(&authed, || format!("apps:write:{}", &app.path))?;
validate_frontend_sdk_scopes(&app.policy)?;
if authed.is_operator {
check_operator_composed_app(raw_app, Some(&app.value.0), Some(&mut app.policy), false)?;
}
if *CLOUD_HOSTED {
let nb_apps =
sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
@@ -2394,13 +2431,16 @@ async fn delete_app(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot delete apps for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "delete apps").await?;
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
// One endpoint deletes both kinds, and builder rights only cover full-code apps.
if authed.is_operator && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
{
return Err(Error::NotAuthorized(
"Operators with builder rights can only delete full-code apps".to_string(),
));
}
if path == "g/all/setup_app" && w_id == "admins" {
return Err(Error::BadRequest(
@@ -2641,6 +2681,10 @@ async fn update_app_raw_source(
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
// Stays closed to operators even with builder rights, unlike `create_app_raw`/`update_app_raw`
// next to it: this path compiles caller-supplied sources with a bundler job on a worker, which
// is arbitrary code execution. Builders lose nothing: the browser and the CLI both bundle
// locally and deploy through the multipart endpoints.
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -2777,6 +2821,10 @@ async fn create_app_raw_source(
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
// Stays closed to operators even with builder rights, unlike `create_app_raw`/`update_app_raw`
// next to it: this path compiles caller-supplied sources with a bundler job on a worker, which
// is arbitrary code execution. Builders lose nothing: the browser and the CLI both bundle
// locally and deploy through the multipart endpoints.
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create apps for security reasons".to_string(),
@@ -2916,11 +2964,7 @@ async fn update_app_raw<'a>(
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "update apps").await?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
@@ -2936,6 +2980,14 @@ async fn update_app_raw<'a>(
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
// `update_app_internal` only compares kinds when a new value is deployed, so a
// policy-only update would otherwise let a builder rewrite a low-code app's policy.
if authed.is_operator && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
{
return Err(Error::NotAuthorized(
"Operators with builder rights can only update full-code apps".to_string(),
));
}
let opath = path.to_string();
let db2 = db.clone();
let (npath, v_id) = process_app_multipart!(
@@ -2976,7 +3028,7 @@ async fn update_app_internal<'a>(
w_id: &str,
path: &str,
raw_app: bool,
ns: EditApp,
mut ns: EditApp,
) -> Result<(sqlx::Transaction<'a, sqlx::Postgres>, String, i64)> {
use sql_builder::prelude::*;
@@ -2986,6 +3038,15 @@ async fn update_app_internal<'a>(
check_scopes(&authed, || format!("apps:write:{}", npath))?;
}
if authed.is_operator {
check_operator_composed_app(
raw_app,
ns.value.as_ref().map(|v| v.0.as_ref()),
ns.policy.as_mut(),
ns.allow_kind_change.unwrap_or(false),
)?;
}
let mut tx = user_db.clone().begin(&authed).await?;
// `app_version.raw_app` is set by whichever endpoint writes the version, so a
@@ -3490,7 +3551,12 @@ async fn execute_component(
let authed = opt_authed.as_ref().ok_or_else(|| {
Error::NotAuthorized("App component preview requires authentication".to_string())
})?;
if authed.is_operator {
// A builder testing the app it is composing only ever previews a deployed runnable
// (`path`) or a persisted app script (`id`), both confined below to what it may read.
// Inline `raw_code` is authoring code, so it stays closed to every operator.
if authed.is_operator
&& (payload.raw_code.is_some() || !operator_builder_enabled(&db, &w_id).await?)
{
return Err(Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
+13 -9
View File
@@ -21,6 +21,7 @@ use windmill_common::{
users::resolve_username_to_email,
utils::strip_json_nul,
variables::{build_crypt, encrypt},
workspaces::operator_builder_enabled,
};
pub fn workspaced_service() -> Router {
@@ -100,10 +101,10 @@ async fn list_drafts(
Path(w_id): Path<String>,
Query(query): Query<ListDraftsQuery>,
) -> Result<Json<Vec<DraftListItem>>> {
// Operators have no drafts of their own (they can't write any, see
// `require_can_write_path`), so this list is always empty for them. They
// can still READ some collaborators' drafts via `/drafts/get`.
if authed.is_operator {
// Without builder rights an operator has no drafts of their own (they can't write any, see
// `require_can_write_path`), so this list is always empty for them. They can still READ some
// collaborators' drafts via `/drafts/get`.
if authed.is_operator && !operator_builder_enabled(&db, &w_id).await? {
return Ok(Json(vec![]));
}
let all_users = query.all_users.unwrap_or(false);
@@ -682,11 +683,14 @@ async fn require_can_write_path(
if authed.is_admin {
return Ok(());
}
// Operators are read-only and never WRITE drafts. Read access is
// deliberately asymmetric: `require_can_read_path` has no operator block,
// so an operator can still READ a draft they can read via `/drafts/get`,
// mirroring their read access to deployed content. Intended.
if authed.is_operator {
// Operators are read-only and never WRITE drafts, except for the two kinds a workspace with
// builder rights lets them author. Read access is deliberately asymmetric:
// `require_can_read_path` has no operator block, so an operator can still READ a draft they
// can read via `/drafts/get`, mirroring their read access to deployed content. Intended.
if authed.is_operator
&& !(matches!(kind, UserDraftItemKind::Flow | UserDraftItemKind::RawApp)
&& operator_builder_enabled(db, w_id).await?)
{
return Err(Error::NotAuthorized(
"operators cannot save drafts".to_string(),
));
+16 -9
View File
@@ -25,6 +25,7 @@ use std::time::Instant;
use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use url::Url;
use windmill_api_flows::flows::validate_operator_composed_flow;
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
use windmill_common::auth::is_super_admin_email;
use windmill_common::auth::TOKEN_PREFIX_LEN;
@@ -53,7 +54,9 @@ use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR};
use windmill_common::workspace_dependencies::{
RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES,
};
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
use windmill_common::workspaces::{
check_operator_can_build, check_user_against_rule, ProtectionRuleKind, RuleCheckResult,
};
use windmill_common::DYNAMIC_INPUT_CACHE;
#[cfg(all(feature = "enterprise", feature = "instance_smtp"))]
use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
@@ -8676,10 +8679,12 @@ async fn push_flow_dependencies_job(
req: RunFlowDependenciesRequest,
) -> error::Result<Uuid> {
check_scopes(authed, || format!("jobs:run"))?;
check_operator_can_build(db, w_id, authed.is_operator, "run dependencies jobs").await?;
// The dependency job locks whatever inline code this request carries, on a worker. A
// composition-only flow has none, so validating here costs a builder nothing and keeps the
// lock step from becoming the way to run code the write path refuses.
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot run dependencies jobs for security reasons".to_string(),
));
validate_operator_composed_flow(&req.flow_value, &None, authed, db, w_id).await?;
}
if req.raw_deps.is_some() {
@@ -9059,15 +9064,17 @@ async fn run_preview_flow_job(
Query(run_query): Query<RunJobQuery>,
Json(raw_flow): Json<PreviewFlow>,
) -> error::Result<(StatusCode, String)> {
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
}
check_operator_can_build(&db, &w_id, authed.is_operator, "run preview jobs").await?;
// Flow preview runs an arbitrary, request-supplied flow definition; require the broad
// jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script.
check_scopes(&authed, || format!("jobs:run"))?;
require_path_read_access_for_preview(&authed, &raw_flow.path)?;
// A builder must be able to test what it composes, but the submitted value is not the stored
// one: without this the preview is a way to run inline code the write path refuses.
if authed.is_operator {
validate_operator_composed_flow(&raw_flow.value, &raw_flow.tag, &authed, &db, &w_id)
.await?;
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(raw_flow.tag.clone());
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
+8
View File
@@ -71,6 +71,14 @@ pub async fn check_seat_cap_for_reactivation(
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn check_seat_cap_for_operator_builder(
_db: &DB,
_w_id: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result<Option<String>> {
// Implementation is not open source
+207
View File
@@ -246,6 +246,137 @@ pub async fn resolve_modules(
Ok(())
}
/// Checks a flow value contains nothing but composition of runnables that already exist, which is
/// all an operator with builder rights may author. Walks the modules, the preprocessor and failure
/// modules, every branch, and the `tools` of an AI agent step.
///
/// Returns the worker tags its steps pin, for the caller to authorize against its own scope: a tag
/// is how a step picks the worker group it runs on.
pub fn check_flow_is_composition_only(value: &FlowValue) -> Result<Vec<String>, Error> {
let mut tags = Vec::new();
for module in value
.modules
.iter()
.chain(value.preprocessor_module.as_deref())
.chain(value.failure_module.as_deref())
{
check_module_is_composition_only(module, &mut tags)?;
}
Ok(tags)
}
fn check_module_is_composition_only(
module: &FlowModule,
tags: &mut Vec<String>,
) -> Result<(), Error> {
let value = module
.get_value()
.map_err(|e| Error::BadRequest(format!("Step {} could not be read: {e}", module.id)))?;
check_module_value_is_composition_only(&value, &module.id, tags)
}
fn check_module_value_is_composition_only(
value: &FlowModuleValue,
id: &str,
tags: &mut Vec<String>,
) -> Result<(), Error> {
let refuse = |what: &str| {
Err(Error::NotAuthorized(format!(
"Step {id}: {what}. Operators with builder rights compose runnables that are already \
deployed; they cannot author code."
)))
};
// A node id points at code stored in a `flow_node` row. Only the dependency job produces them,
// by hoisting a step's code out of the flow value, so an authored value carrying one names
// code that belongs to some other flow. `modules` is what the walk below covers, and an
// editor payload comes from the un-hoisted `flow_version.value`, so refusing them costs
// nothing legitimate.
let refuse_node = |node: &Option<FlowNodeId>| match node {
Some(_) => refuse("references code stored outside the flow"),
None => Ok(()),
};
let mut push_tag = |tag: &Option<String>| {
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
tags.push(tag.to_string());
}
};
match value {
FlowModuleValue::RawScript { .. } => return refuse("has inline code"),
FlowModuleValue::FlowScript { .. } => {
return refuse("references code stored outside the flow")
}
FlowModuleValue::Identity => {}
FlowModuleValue::Script { path, tag_override, .. } => {
check_composable_path(path, id)?;
push_tag(tag_override);
}
FlowModuleValue::Flow { path, .. } => check_composable_path(path, id)?,
FlowModuleValue::ForloopFlow { modules, modules_node, .. }
| FlowModuleValue::WhileloopFlow { modules, modules_node, .. } => {
refuse_node(modules_node)?;
for module in modules {
check_module_is_composition_only(module, tags)?;
}
}
FlowModuleValue::BranchOne { branches, default, default_node } => {
refuse_node(default_node)?;
for module in default {
check_module_is_composition_only(module, tags)?;
}
check_branches_are_composition_only(branches, id, tags)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
check_branches_are_composition_only(branches, id, tags)?
}
FlowModuleValue::AIAgent { tools, tag, agent, .. } => {
// A linked agent resolves its tools from an `ai_agent` resource at run time, and
// operators may write resources, so those tools are outside this check: the list can
// be swapped for a raw script after the flow is deployed.
if agent.is_some() {
return refuse("links an AI agent resource, whose tools live outside the flow");
}
push_tag(tag);
for tool in tools {
if let ToolValue::FlowModule(value) = &tool.value {
check_module_value_is_composition_only(value, &tool.id, tags)?;
}
}
}
}
Ok(())
}
fn check_branches_are_composition_only(
branches: &[Branch],
id: &str,
tags: &mut Vec<String>,
) -> Result<(), Error> {
for branch in branches {
if branch.modules_node.is_some() {
return Err(Error::NotAuthorized(format!(
"Step {id}: a branch references code stored outside the flow. Operators with \
builder rights compose runnables that are already deployed; they cannot author \
code."
)));
}
for module in &branch.modules {
check_module_is_composition_only(module, tags)?;
}
}
Ok(())
}
fn check_composable_path(path: &str, id: &str) -> Result<(), Error> {
if path.starts_with("hub/") {
return Err(Error::NotAuthorized(format!(
"Step {id}: hub runnables are not available to operators with builder rights. Deploy \
it to the workspace first."
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -281,4 +412,80 @@ mod tests {
let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
fn flow(value: serde_json::Value) -> FlowValue {
serde_json::from_value(value).unwrap()
}
#[test]
fn composition_check_accepts_a_composed_flow_and_collects_its_tags() {
let tags = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [{
"id": "a",
"value": {"type": "forloopflow", "iterator": {"type": "static", "value": []},
"parallel": false, "modules": [
{"id": "b", "value": {"type": "script", "path": "f/x/s", "tag_override": "gpu"}},
{"id": "c", "value": {"type": "flow", "path": "f/x/f"}},
{"id": "d", "value": {"type": "aiagent", "input_transforms": {}, "tag": "ai",
"tools": [{"id": "t", "value": {"tool_type": "flowmodule",
"type": "script", "path": "f/x/tool"}}]}}
]}
}]})))
.unwrap();
assert_eq!(tags, vec!["gpu".to_string(), "ai".to_string()]);
}
/// The walk covers `modules`, so a node reference is a way past it: it names code hoisted
/// into a `flow_node` row, possibly another flow's.
#[test]
fn composition_check_rejects_node_references() {
for value in [
serde_json::json!({"modules": [{"id": "a", "value": {"type": "forloopflow",
"iterator": {"type": "static", "value": []}, "parallel": false,
"modules": [], "modules_node": 7}}]}),
serde_json::json!({"modules": [{"id": "a", "value": {"type": "branchone",
"branches": [], "default": [], "default_node": 7}}]}),
serde_json::json!({"modules": [{"id": "a", "value": {"type": "branchall",
"branches": [{"expr": "true", "modules": [], "modules_node": 7}]}}]}),
serde_json::json!({"modules": [{"id": "a", "value": {"type": "flowscript",
"id": 7, "language": "bun"}}]}),
] {
assert!(check_flow_is_composition_only(&flow(value)).is_err());
}
}
/// An agent tool wraps a whole `FlowModuleValue`, and a linked agent resolves its tools from a
/// resource an operator may rewrite after the flow is deployed.
#[test]
fn composition_check_rejects_code_reachable_through_an_ai_agent() {
for value in [
serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent",
"input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule",
"type": "rawscript", "content": "x", "language": "bun"}}]}}]}),
serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent",
"input_transforms": {}, "tools": [], "agent": "$res:f/x/agent"}}]}),
] {
assert!(check_flow_is_composition_only(&flow(value)).is_err());
}
}
#[test]
fn composition_check_rejects_code_in_every_module_slot() {
let inline = serde_json::json!({"type": "rawscript", "content": "x", "language": "bun"});
for value in [
serde_json::json!({"modules": [{"id": "a", "value": inline}]}),
serde_json::json!({"modules": [], "failure_module": {"id": "f", "value": inline}}),
serde_json::json!({"modules": [], "preprocessor_module": {"id": "p", "value": inline}}),
] {
assert!(check_flow_is_composition_only(&flow(value)).is_err());
}
}
#[test]
fn composition_check_rejects_hub_runnables() {
for kind in ["script", "flow"] {
let value = serde_json::json!({"modules": [{"id": "a",
"value": {"type": kind, "path": "hub/1234/thing"}}]});
assert!(check_flow_is_composition_only(&flow(value)).is_err());
}
}
}
+45 -1
View File
@@ -182,8 +182,13 @@ pub fn canonical_base_url(input: &str) -> String {
}
/// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer).
///
/// Never an operator, whatever their groups: membership of `wm_deployers` can be granted by that
/// group's owner rather than by a workspace admin, so an operator who edits a runnable would
/// otherwise keep it pointed at the identity of the admin who first authored it.
pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool {
authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP)
!authed.is_operator()
&& (authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP))
}
/// Checks if on-behalf-of preservation actually happened (the target user differs from the acting user).
@@ -2397,3 +2402,42 @@ impl KillpillSender {
// self.already_sent.load(Ordering::SeqCst)
// }
}
#[cfg(test)]
mod on_behalf_of_tests {
use super::*;
use crate::db::Authed;
fn authed(is_admin: bool, is_operator: bool, groups: &[&str]) -> Authed {
Authed {
email: "u@windmill.dev".to_string(),
username: "u".to_string(),
is_admin,
is_operator,
groups: groups.iter().map(|g| g.to_string()).collect(),
folders: vec![],
scopes: None,
token_prefix: None,
}
}
#[test]
fn operators_never_preserve_on_behalf_of() {
// A group owner can put anyone in `wm_deployers`, so the group alone must not let an
// operator keep a runnable pointed at the identity of the admin who authored it.
assert!(!can_preserve_on_behalf_of(&authed(
false,
true,
&[WM_DEPLOYERS_GROUP]
)));
assert!(!can_preserve_on_behalf_of(&authed(true, true, &[])));
assert!(can_preserve_on_behalf_of(&authed(true, false, &[])));
assert!(can_preserve_on_behalf_of(&authed(
false,
false,
&[WM_DEPLOYERS_GROUP]
)));
assert!(!can_preserve_on_behalf_of(&authed(false, false, &[])));
}
}
+81 -2
View File
@@ -762,7 +762,8 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result<i64> {
/// Approximate paid seats of a workspace as `ceil(developers + operators/2)`, excluding disabled and
/// service-account members. Reuses billing's author/operator weighting, but counts provisioned
/// members rather than the active-user population billing meters, so it only ever loosens the fork
/// cap (never blocks a paid seat) good enough for a soft guardrail.
/// cap (never blocks a paid seat), good enough for a soft guardrail. Operators of a workspace
/// with builder rights author flows and apps, so they weigh a full seat like developers.
///
/// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be
/// authorized for that workspace (or run in trusted server-side code).
@@ -778,7 +779,12 @@ pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("counting paid seats of {w_id}: {e:#}")))?;
Ok(((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64)
let operator_weight = if operator_builder_enabled(db, w_id).await? {
1.0
} else {
0.5
};
Ok(((row.developers as f64) + operator_weight * (row.operators as f64)).ceil() as i64)
}
#[cfg(feature = "cloud")]
@@ -891,6 +897,79 @@ pub fn invalidate_protection_rules_cache(workspace_id: &str) {
PROTECTION_RULES_CACHE.remove(workspace_id);
}
// Operator builder rights cache
lazy_static::lazy_static! {
static ref OPERATOR_BUILDER_CACHE: Cache<String, (bool, i64)> = Cache::new(1000);
}
/// Whether operators of this workspace may compose flows and raw apps out of already-deployed
/// runnables. All-or-nothing per workspace: it is a `builder` flag on `operator_settings`, not a
/// per-user role, so every operator of the workspace gets it (and consumes a full seat).
///
/// Read on every flow/app write and preview, so it is cached with a 60s TTL. Invalidation is
/// per-process (see [`invalidate_operator_builder_cache`]): another server in the fleet keeps
/// serving the old value until its own entry expires.
pub async fn operator_builder_enabled(db: &DB, workspace_id: &str) -> Result<bool> {
let now = chrono::Utc::now().timestamp();
if let Some((enabled, expiry)) = OPERATOR_BUILDER_CACHE.get(workspace_id) {
if expiry > now {
return Ok(enabled);
}
}
let enabled = sqlx::query_scalar!(
"SELECT COALESCE((operator_settings->>'builder')::boolean, false)
FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_optional(db)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to fetch operator builder setting for {workspace_id}: {e:#}"
))
})?
.flatten()
.unwrap_or(false);
OPERATOR_BUILDER_CACHE.insert(workspace_id.to_string(), (enabled, now + 60));
Ok(enabled)
}
/// Invalidate the operator builder cache for a workspace
pub fn invalidate_operator_builder_cache(workspace_id: &str) {
OPERATOR_BUILDER_CACHE.remove(workspace_id);
}
/// Gate for a write that operators may perform only where the workspace granted them builder
/// rights. `action` completes "Operators cannot {action} for security reasons".
pub async fn check_operator_can_build(
db: &DB,
workspace_id: &str,
is_operator: bool,
action: &str,
) -> Result<()> {
if is_operator && !operator_builder_enabled(db, workspace_id).await? {
return Err(Error::NotAuthorized(format!(
"Operators cannot {action} for security reasons"
)));
}
Ok(())
}
/// Whether a membership consumes an operator (half) seat rather than an author seat. An operator
/// of a workspace with builder rights authors flows and apps, so billing counts them as an author.
pub async fn consumes_operator_seat(
db: &DB,
workspace_id: &str,
is_operator: bool,
) -> Result<bool> {
Ok(is_operator && !operator_builder_enabled(db, workspace_id).await?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleCheckResult {
Allowed,
@@ -21,7 +21,7 @@ async fn list_repos(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<GithubRepoEntry>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&authed, &db, &workspace_id).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?;
let mut all_entries = Vec::new();
@@ -93,7 +93,7 @@ async fn list_calendars(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<GoogleCalendarEntry>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&authed, &db, &workspace_id).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
@@ -126,7 +126,7 @@ async fn list_drive_files(
Path(workspace_id): Path<String>,
Query(query): Query<DriveFilesQuery>,
) -> JsonResult<GoogleDriveFilesResponse> {
require_native_integration_use(&authed)?;
require_native_integration_use(&authed, &db, &workspace_id).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let drive_query = if query.shared_with_me {
@@ -201,7 +201,7 @@ async fn list_shared_drives(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<SharedDriveEntry>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&authed, &db, &workspace_id).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
+6 -4
View File
@@ -49,6 +49,7 @@ use windmill_common::{
triggers::TriggerKind,
utils::HTTP_CLIENT,
variables::{build_crypt, decrypt, encrypt},
workspaces::operator_builder_enabled,
DB,
};
use windmill_queue::PushArgsOwned;
@@ -1633,10 +1634,11 @@ pub async fn store_workspace_integration(
/// Authorization gate for the integration *use* routes (calendar/drive/repo/event
/// pickers). A workspace admin configures the integration, but any member who can
/// create a native trigger needs the pickers to configure one. Operators are
/// read-only and cannot create triggers, so they must not be able to drive the
/// admin-configured integration's upstream API and enumerate its data.
pub fn require_native_integration_use(authed: &ApiAuthed) -> Result<()> {
if authed.is_operator {
/// read-only, so they must not be able to drive the admin-configured integration's
/// upstream API and enumerate its data, unless the workspace granted them builder
/// rights, which is what makes them trigger authors.
pub async fn require_native_integration_use(authed: &ApiAuthed, db: &DB, w_id: &str) -> Result<()> {
if authed.is_operator && !operator_builder_enabled(db, w_id).await? {
return Err(Error::NotAuthorized(
"Operators cannot use workspace integrations".to_string(),
));
@@ -21,7 +21,7 @@ async fn list_available_events<T: External>(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<NextCloudEventType>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&authed, &db, &workspace_id).await?;
let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?;
let base_url = integration
+65
View File
@@ -0,0 +1,65 @@
# Operator builder rights
A workspace setting (`operator_settings.builder`) that lets every operator of that workspace
compose flows and full-code apps out of runnables that already exist. It does not make them
authors: the boundary the operator role draws is **authoring code and running arbitrary code**,
and builder rights do not move it.
Read the flag with `windmill_common::workspaces::operator_builder_enabled` (60s cache, invalidated
on write by `update_operator_settings`). Gate a write with `check_operator_can_build`.
## What the check has to cover
`check_flow_is_composition_only` (`windmill-common/src/flows.rs`) walks a `FlowValue` and refuses
anything that carries code. Three of its rules exist because the obvious walk misses them:
- **`FlowScript` and any populated `modules_node` / `default_node`.** These name code hoisted into
a `flow_node` row. Only the dependency job produces them, so an authored value carrying one
names code stored under some other flow. The walk covers `modules`, so a node reference is a way
past it.
- **An AI agent step's `tools`.** `ToolValue::FlowModule` wraps a whole `FlowModuleValue`, so a
tool can be a raw script.
- **An AI agent step's `agent` link.** A linked agent resolves its tools from an `ai_agent`
resource at run time, and operators may write resources, so the tool list is outside this check
and can be swapped for a raw script after the flow is approved.
It also returns the worker tags the steps pin. Authorize them (`check_tag_available_for_workspace_internal`)
or a builder routes a job onto a privileged worker group.
Call it on every write **and** every preview: `run_preview_flow_job` and
`push_flow_dependencies_job` both take a request-supplied flow value, so leaving either out makes
it the way to run what the write path refuses.
## The two raw-app deploy paths are not equivalent
`create_app_raw` / `update_app_raw` are multipart: the browser already built the bundle, and
nothing server-side compiles anything. Builders use these.
`create_app_raw_source` / `update_app_raw_source` push a bundler CLI over caller-supplied `files`
as a job **on a worker**, which is arbitrary code execution and is why they already require `jobs:run`
on top of `apps:write`. They stay closed to operators, builder rights or not. Do not "tidy" the
exception away: it costs builders nothing, because the browser and the CLI both bundle locally and
deploy through the multipart endpoints.
A builder-authored app is forced to `policy.sandbox = true` (`check_operator_composed_app`). That
is what makes it safe to let an operator publish a bundle nobody reviewed: without it the bundle
runs same-origin with each viewer's Windmill session.
## Accepted risks
- A builder raw app may declare `frontend_sdk_scopes`, and `mint_raw_app_sdk_token` mints as the
*viewer*. A consenting admin therefore hands the bundle a 12h admin-identity token within the
curated scope list. The viewer consent prompt is the gate. The lever, if this is ever revisited,
is dropping `variables:read` / `resources:read` from `FRONTEND_SDK_ALLOWED_SCOPES` for builder
apps.
- All-or-nothing per workspace: there is no per-user builder role.
- `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and
the billed seat count with it.
## Billing
An operator of a builder workspace consumes a full author seat. `consumes_operator_seat` is the
seat-role helper; the EE counting queries share `OPERATOR_SEAT_SQL` so the displayed, enforced and
reported numbers agree. Enabling the setting runs `check_seat_cap_for_operator_builder`, which
prices the change by counting seats twice rather than by counting the workspace's operators: an
operator who already authors elsewhere must not be charged again.
@@ -19,6 +19,7 @@
} from '$lib/components/flows/linkedAgentToolsStore.svelte'
import {
enterpriseLicense,
operatorBuilderRights,
userStore,
userWorkspaces,
workspaceStore,
@@ -1356,7 +1357,7 @@
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
{#key renderCount}
{#if !$userStore?.operator}
{#if !$userStore?.operator || $operatorBuilderRights}
{#if $pathStore}
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
{/if}
@@ -1517,7 +1518,9 @@
{forceTestTab}
{highlightArg}
aiChatOpen={aiChatManager.open}
showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false}
showFlowAiButton={!disableAi &&
customUi?.topBar?.aiBuilder != false &&
!$operatorBuilderRights}
toggleAiChat={() => aiChatManager.toggleOpen()}
{sessionOpen}
onOpenPreview={flowPreviewButtons?.openPreview}
@@ -1546,7 +1549,9 @@
{/if}
</div>
{:else}
Flow Builder not available to operators
<div class="h-full w-full center-center text-sm text-secondary">
Flow builder not available to operators
</div>
{/if}
{/key}
@@ -2,7 +2,7 @@
import { Alert } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { enterpriseLicense, operatorBuilderRights, userStore, workspaceStore } from '$lib/stores'
import { Loader2 } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -79,6 +79,15 @@
const opWs = $derived(operatingWorkspace ?? $workspaceStore)
// The backend refuses an unsandboxed app from an operator with builder rights, since the
// bundle would otherwise run with the viewer's own Windmill session. Pin it here too, so the
// editor preview behaves like the deployed app rather than diverging until the first deploy.
$effect(() => {
if ($operatorBuilderRights && policy.sandbox !== true) {
policy.sandbox = true
}
})
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
// Admins always pass the backend check. For everyone else, fail closed
// while the workspace protection rules are still loading so the toggle
@@ -297,7 +306,7 @@
<div class="my-6">
<Toggle
options={{ right: "Isolate the app from the viewer's browser session" }}
checked={policy.sandbox == true}
checked={$operatorBuilderRights || policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
// Frontend API access exists only for a sandboxed app, so turning
@@ -314,8 +323,13 @@
setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled')
}
}}
disabled={!savedApp}
disabled={!savedApp || $operatorBuilderRights}
/>
{#if $operatorBuilderRights}
<div class="text-xs text-secondary mt-1">
Required for apps built by operators, and cannot be turned off.
</div>
{/if}
<div class="text-xs text-secondary mt-1">
Controls what the app's browser-side code can reach in each viewer's browser — distinct from the
on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code
@@ -14,7 +14,7 @@
import { defaultCode } from '../component'
import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte'
import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte'
import { defaultScripts, isCurrentlyInTutorial } from '$lib/stores'
import { defaultScripts, isCurrentlyInTutorial, operatorBuilderRights } from '$lib/stores'
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
import type { Preview } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
@@ -105,7 +105,9 @@
<Tabs bind:selected={tab}>
<Tab value="workspacescripts" label="Workspace Scripts" icon={Building} />
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
{#if !$operatorBuilderRights}
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
{/if}
</Tabs>
<div class="my-2"></div>
<div class="flex flex-col gap-y-16">
@@ -130,21 +132,25 @@
id="app-editor-empty-runnable"
>
<div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header">
<div class="font-bold items-baseline truncate">Choose a language</div>
<div class="font-bold items-baseline truncate">
{$operatorBuilderRights ? 'Choose a script or flow' : 'Choose a language'}
</div>
<div class="flex gap-2">
{#if showScriptPicker}
<RunnableSelector {unusedInlineScripts} {rawApps} on:pick hideCreateScript />
{/if}
<Button
on:click={() => picker?.openDrawer()}
size="xs"
variant="border"
color="light"
startIcon={{ icon: GitFork }}
btnClasses="truncate"
>
Fork other script
</Button>
{#if !$operatorBuilderRights}
<Button
on:click={() => picker?.openDrawer()}
size="xs"
variant="border"
color="light"
startIcon={{ icon: GitFork }}
btnClasses="truncate"
>
Fork other script
</Button>
{/if}
<Button
on:click={() => dispatch('delete')}
@@ -158,7 +164,9 @@
</div>
</div>
<div class="flex flex-row w-full gap-8">
<!-- Operators with builder rights compose runnables that already exist: the language pickers
below all author code, which the backend refuses from them. -->
<div class="flex flex-row w-full gap-8" class:hidden={$operatorBuilderRights}>
<div id="app-editor-backend-runnables">
<div class="mb-1 text-sm font-semibold flex gap-4">Backend <DefaultScripts /> </div>
@@ -19,7 +19,7 @@
CtxAppInput
} from '../../inputType'
import type { AppViewerContext } from '../../types'
import { workspaceStore } from '$lib/stores'
import { workspaceStore, operatorBuilderRights } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { deepEqual } from 'fast-equals'
import { computeFields } from './utils'
@@ -360,16 +360,18 @@
>
Edit
</Button>
<Button
size="xs"
variant="default"
startIcon={{ icon: GitFork }}
on:click={() => {
fork(runnable.path)
}}
>
Fork
</Button>
{#if !$operatorBuilderRights}
<Button
size="xs"
variant="default"
startIcon={{ icon: GitFork }}
on:click={() => {
fork(runnable.path)
}}
>
Fork
</Button>
{/if}
{/if}
<Popover
floatingConfig={{
@@ -11,7 +11,7 @@
import type { Schema } from '$lib/common'
import { emptySchema } from '$lib/utils'
import { loadSchema } from '$lib/infer'
import { workspaceStore } from '$lib/stores'
import { workspaceStore, operatorBuilderRights } from '$lib/stores'
import { buildPathRunnableSelection } from './runnableSelectorUtils'
type TabType =
@@ -166,10 +166,12 @@
<Tab value="workspacescripts" label="Workspace Scripts" icon={Building} />
{/if}
<Tab value="workspaceflows" label="Workspace Flows" icon={Building} />
{#if !onlyFlow}
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
{#if !$operatorBuilderRights}
{#if !onlyFlow}
<Tab value="hubscripts" label="Hub Scripts" icon={Globe2} />
{/if}
<Tab value="hubflows" label="Hub Flows" icon={Globe2} />
{/if}
<Tab value="hubflows" label="Hub Flows" icon={Globe2} />
</Tabs>
<div class="my-2"></div>
<div class="flex flex-col gap-y-16">
@@ -6,7 +6,7 @@
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { AppService, type ListableApp } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { operatorBuilderRights, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
@@ -209,7 +209,7 @@
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
@@ -271,7 +271,7 @@
displayName: 'Deployments',
icon: History,
action: () => appDeploymentHistory?.open(),
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: 'Permissions',
@@ -279,7 +279,7 @@
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'app')
},
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: 'Copy path',
@@ -325,7 +325,7 @@
},
type: 'delete',
disabled: !canEdit,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
}
]
}}
@@ -8,7 +8,7 @@
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { operatorBuilderRights, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
@@ -228,7 +228,7 @@
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
}
]
}
@@ -243,7 +243,7 @@
icon: GitFork,
href: `${base}/flows/add?template=${path}`,
disabled: !showEditButton,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
@@ -269,7 +269,7 @@
moveDrawer.openDrawer(path, flow.summary, 'flow')
},
disabled: !owner || archived || !canEdit,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: 'Copy path',
@@ -297,7 +297,7 @@
action: () => {
flowHistory?.open()
},
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: 'Schedule',
@@ -306,7 +306,7 @@
scheduleEditor?.openNew(true, path)
},
disabled: archived,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: 'Permissions',
@@ -314,7 +314,7 @@
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
},
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: archived ? 'Unarchive' : 'Archive',
@@ -324,7 +324,7 @@
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
},
{
displayName: 'Delete',
@@ -341,7 +341,7 @@
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator
hide: $userStore?.operator && !$operatorBuilderRights
}
]
}}
@@ -44,6 +44,7 @@ import { loadApiTools } from './api/apiTools'
import { prepareScriptUserMessage } from './script/core'
import { prepareNavigatorUserMessage } from './navigator/core'
import { sendUserToast } from '$lib/toast'
import { operatorBuilderRights } from '$lib/stores'
import { workspaceAIClients, getNonStreamingCompletion } from '../lib'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { modelSupportsVision } from '../modelConfig'
@@ -248,6 +249,11 @@ export function supportsPlanMode(mode: AIMode): boolean {
return PLAN_MODES.has(mode)
}
// Rune mirror of the store: a class `$derived` cannot track a store read, and this one gates
// which chat modes exist.
const isOperatorBuilder = $state({ val: false })
operatorBuilderRights.subscribe((v) => (isOperatorBuilder.val = v))
export function isAIModeVisible(mode: AIMode): boolean {
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
}
@@ -1107,13 +1113,17 @@ export class AIChatManager {
.map((s) => ({ ...s, kind: 'skill' as const }))
])
// The flow, app and script builders all write code, which the backend refuses from an
// operator with builder rights: leaving them reachable would only produce work that
// cannot be deployed.
allowedModes: Record<AIMode, boolean> = $derived({
script:
this.flowAiChatHelpers === undefined &&
this.scriptEditorOptions !== undefined &&
!this.disabledModes.script,
flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow,
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app,
!this.disabledModes.script &&
!isOperatorBuilder.val,
flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow && !isOperatorBuilder.val,
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app && !isOperatorBuilder.val,
navigator: !this.disabledModes.navigator,
ask: !this.disabledModes.ask,
API: !this.disabledModes.API,
@@ -19,7 +19,7 @@
import FlowPanelChrome from './FlowPanelChrome.svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
import { hubBaseUrlStore, operatorBuilderRights, workspaceStore } from '$lib/stores'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import { getLatestHashForScript } from '$lib/scripts'
import { sendUserToast, type Item } from '$lib/utils'
@@ -94,7 +94,7 @@
const scriptItems: Item[] = $derived.by(() => {
if (flowModuleValue?.type !== 'script') return []
const items: Item[] = []
if (!isHub && customUi?.scriptEdit != false) {
if (!isHub && customUi?.scriptEdit != false && !$operatorBuilderRights) {
items.push({
displayName: "Edit the script's code",
icon: Pen,
@@ -129,7 +129,7 @@
})
}
}
if (customUi?.scriptFork != false) {
if (customUi?.scriptFork != false && !$operatorBuilderRights) {
items.push({
displayName: 'Fork into an inline script',
icon: GitFork,
@@ -11,6 +11,7 @@
defaultScripts,
enterpriseLicense,
hubBaseUrlStore,
operatorBuilderRights,
userStore,
workspaceStore
} from '$lib/stores'
@@ -156,7 +157,10 @@
preFilter: 'all' | 'workspace' | 'hub',
selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
) {
if (['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)) {
if (
!$operatorBuilderRights &&
['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)
) {
if (!selected && preFilter == 'all') {
inlineScripts = langs.filter((lang) => {
return (
@@ -255,6 +259,7 @@
// on indices that render nothing.
let showAiRows = $derived(
!disableAi &&
!$operatorBuilderRights &&
funcDesc?.length > 0 &&
kind != 'failure' &&
kind != 'preprocessor' &&
@@ -283,9 +288,14 @@
preFilter === 'all' &&
!selected &&
customUi?.aiSandbox != false &&
!$operatorBuilderRights &&
matchesAiSandbox
)
// Hub runnables carry code the workspace never reviewed, and the backend refuses them in a
// flow a builder authors, so the hub browser and its integration filters are not offered.
let showHub = $derived(!$operatorBuilderRights)
// Every result row lives in one keyboard index space, and hovering a row moves that index, so
// mouse and keyboard can never highlight two different rows. Offsets follow the render order.
let inlineOffset = $derived(topLevelNodes.length)
@@ -338,7 +348,7 @@
{/if}
{/if}
{#if preFilter === 'hub' || preFilter === 'all'}
{#if showHub && (preFilter === 'hub' || preFilter === 'all')}
{#if preFilter == 'all'}
<div class="pb-0 text-2xs font-normal text-secondary ml-2 pt-1">Integrations</div>
{/if}
@@ -543,7 +553,7 @@
}}
/>
{/if}
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
{#if showHub && selectedKind != 'preprocessor' && selectedKind != 'flow'}
{#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')}
{#if !selected && preFilter !== 'hub'}
<div class=" pb-0 text-2xs font-normal text-secondary ml-2">Hub</div>
@@ -36,7 +36,7 @@
import type { ButtonProp } from '$lib/components/diffEditorTypes'
import { loadSchemaFromModule } from '../flowInfers'
import { type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { operatorBuilderRights, workspaceStore } from '$lib/stores'
import { checkIfParentLoop } from '../utils.svelte'
import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte'
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
@@ -206,6 +206,7 @@
!flowModule.value.path?.startsWith('hub/') &&
flowModule.value.hash == undefined &&
customUi?.scriptEdit != false &&
!$operatorBuilderRights &&
$workspaceScriptSettingsDrawer != undefined
)
let workspaceScriptNoEditReason = $derived(
@@ -10,6 +10,7 @@
import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte'
import TopLevelNode from '../pickers/TopLevelNode.svelte'
import RefreshButton from '$lib/components/common/button/RefreshButton.svelte'
import { operatorBuilderRights } from '$lib/stores'
const dispatch = createEventDispatcher()
interface Props {
@@ -43,7 +44,11 @@
| 'flow'
| 'failure'
| 'aisandbox' = $state(untrack(() => kind))
let preFilter: 'all' | 'workspace' | 'hub' = $state('all')
// Builders compose what the workspace already deployed: hub scripts bring in code nobody here
// reviewed, and the backend refuses them, so never open on the hub for them.
let preFilter: 'all' | 'workspace' | 'hub' = $state(
untrack(() => $operatorBuilderRights) ? 'workspace' : 'all'
)
let loading = $state(false)
let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure'))
@@ -67,13 +72,13 @@
<div class="flex flex-row items-center gap-2">
<StepGenQuick
on:escape={() => dispatch('close')}
{disableAi}
disableAi={disableAi || $operatorBuilderRights}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
{#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !$operatorBuilderRights}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
<RefreshButton
@@ -190,7 +195,7 @@
}}
/>
{/if}
{#if customUi?.aiSandbox != false}
{#if customUi?.aiSandbox != false && !$operatorBuilderRights}
<TopLevelNode
label="AI Sandbox"
selected={selectedKind === 'aisandbox'}
@@ -24,6 +24,7 @@
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { importStore } from '$lib/components/apps/store'
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { operatorBuilderRights } from '$lib/stores'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import YAML from 'yaml'
@@ -227,9 +228,15 @@
}
}
let activeKey = $state(allOptions[0]?.key)
// A builder composes runnables that already exist, so only the two kinds it may author are
// offered. Everything else here writes code, which the backend refuses from an operator.
const options: Option[] = $operatorBuilderRights
? allOptions.filter((o) => o.key === 'flow' || o.key === 'app-fullcode')
: allOptions
let activeKey = $state(options[0]?.key)
// every option's import action, surfaced together under the bottom "Import" submenu
const importActions: Extra[] = allOptions.flatMap((o) => o.extras ?? [])
const importActions: Extra[] = options.flatMap((o) => o.extras ?? [])
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
// close all come for free; we only drive the doc panel off the highlighted item.
@@ -321,7 +328,7 @@
// only persist the non-default (hidden) state, so a cleared key means "shown"
storeLocalSetting(SHOW_DOC_SETTING, value ? undefined : 'false')
}
let active = $derived(allOptions.find((o) => o.key === activeKey) ?? allOptions[0])
let active = $derived(options.find((o) => o.key === activeKey) ?? options[0])
let activeAc = $derived(accentClasses[active.accent])
// shared YAML/JSON import drawer, reused by every "Import …" extra
@@ -454,7 +461,7 @@
</span>
{/if}
{/snippet}
{#each allOptions as option (option.key)}
{#each options as option (option.key)}
{@const ac = accentClasses[option.accent]}
{@const rowClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
@@ -16,7 +16,7 @@
} from '$lib/gen'
import { resource } from 'runed'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { operatorBuilderRights, userStore, workspaceStore } from '$lib/stores'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
@@ -213,7 +213,11 @@
canWrite:
canWrite(it.path, (it.extra_perms ?? {}) as any, $userStore) &&
(it.type === 'script' || it.workspace_id == $workspaceStore) &&
!$userStore?.operator
(!$userStore?.operator ||
// Builder rights cover flows and full-code apps; a script or a low-code app is
// still off limits, so the row must not offer edit or delete for those.
($operatorBuilderRights &&
(it.type === 'flow' || (it.type === 'app' && it.raw_app === true))))
}
// combinedItems reads a script's time from `created_at`; the endpoint's
// unified `edited_at` holds exactly that for scripts.
@@ -6,6 +6,8 @@
import Section from '$lib/components/Section.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -25,18 +27,24 @@
workers: true
})
let originalSettings = $state({ ...untrack(() => operatorWorkspaceSettings) })
// Kept out of `operatorWorkspaceSettings` so the visibility table's "Enable all" never flips a
// write right, and so its own row stays out of that table.
let builder = $state(false)
let originalSettings = $state({ ...untrack(() => operatorWorkspaceSettings), builder: false })
let isChanged = $state(false)
let currentWorkspace: string | null = $state(null)
let confirmBuilderOpen = $state(false)
const settingsPayload = $derived({ ...operatorWorkspaceSettings, builder })
async function saveSettings() {
console.log('Saving operator settings:', operatorWorkspaceSettings)
try {
await WorkspaceService.updateOperatorSettings({
workspace: $workspaceStore!,
requestBody: operatorWorkspaceSettings
requestBody: settingsPayload
})
originalSettings = { ...operatorWorkspaceSettings }
originalSettings = { ...settingsPayload }
isChanged = false
sendUserToast('Operator settings saved successfully!', false)
} catch (error) {
@@ -45,6 +53,14 @@
}
}
function onSaveClicked() {
if (builder && !originalSettings.builder) {
confirmBuilderOpen = true
} else {
saveSettings()
}
}
const descriptions = {
runs: { title: 'Runs', description: 'View runs' },
schedules: { title: 'Schedules', description: 'View schedules' },
@@ -66,18 +82,17 @@
workspace: $workspaceStore
})
if (settings.operator_settings !== null) {
operatorWorkspaceSettings = {
...operatorWorkspaceSettings,
...(settings.operator_settings ?? {})
}
originalSettings = { ...operatorWorkspaceSettings }
const { builder: remoteBuilder, ...remoteVisibility } = settings.operator_settings ?? {}
operatorWorkspaceSettings = { ...operatorWorkspaceSettings, ...remoteVisibility }
builder = remoteBuilder ?? false
originalSettings = { ...operatorWorkspaceSettings, builder }
}
})()
}
})
$effect(() => {
isChanged = JSON.stringify(operatorWorkspaceSettings) !== JSON.stringify(originalSettings)
isChanged = JSON.stringify(settingsPayload) !== JSON.stringify(originalSettings)
})
const allDisabled = $derived(
@@ -96,7 +111,7 @@
>
{#snippet action()}
<Button
on:click={saveSettings}
on:click={onSaveClicked}
startIcon={{ icon: SaveIcon }}
disabled={!isChanged}
variant="accent"
@@ -105,6 +120,19 @@
</Button>
{/snippet}
<div class="flex flex-col gap-y-1 mb-4">
<span class="text-xs font-semibold text-emphasis">Builder rights</span>
<span class="text-xs font-normal text-secondary">
Let operators compose flows and raw apps out of scripts and flows that are already deployed.
They still cannot write code. Each operator then consumes a full seat instead of half a seat.
</span>
<Toggle
bind:checked={builder}
options={{ right: 'Operators can build flows and raw apps' }}
size="xs"
/>
</div>
<DataTable tableFixed={true} size="xs">
<Head>
<tr>
@@ -157,3 +185,26 @@
</tbody>
</DataTable>
</Section>
<ConfirmationModal
open={confirmBuilderOpen}
title="Give operators builder rights"
confirmationText="Enable builder rights"
onCanceled={() => (confirmBuilderOpen = false)}
onConfirmed={async () => {
confirmBuilderOpen = false
await saveSettings()
}}
>
<div class="flex flex-col gap-2 text-sm">
<span>This applies to every operator of this workspace, not to a chosen few.</span>
<span>
Each of them then consumes a full seat instead of half a seat, which changes what this
instance is billed.
</span>
<span>
They can create, edit and delete flows and raw apps wherever their folder permissions already
let them write. Review those permissions before enabling.
</span>
</div>
</ConfirmationModal>
+13
View File
@@ -165,6 +165,19 @@ export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
}
)
/**
* True when the current user is an operator of a workspace that granted operators builder rights:
* they compose flows and raw apps out of runnables that are already deployed, but still author no
* code. Everywhere else `operator` keeps meaning read-only, so a gate on the operator role has to
* consult this before refusing.
*/
export const operatorBuilderRights: Readable<boolean> = derived(
[userStore, userWorkspaces, workspaceStore],
([user, workspaces, workspace]) =>
(user?.operator ?? false) &&
workspaces.find((w) => w.id === workspace)?.operator_settings?.builder === true
)
export const codeCompletionLoading = writable<boolean>(false)
export const metadataCompletionEnabled = writable<boolean>(true)
export const stepInputCompletionEnabled = writable<boolean>(true)
@@ -1,6 +1,6 @@
<script lang="ts">
import { AppService, FlowService, type OpenFlow, type Script } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { operatorBuilderRights, userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
@@ -308,7 +308,7 @@
>
CLI / MCP
</Button>
{#if !$userStore?.operator && showCreateButtons}
{#if (!$userStore?.operator || $operatorBuilderRights) && showCreateButtons}
<div class="ml-2">
<CreateActionsMenu />
</div>
@@ -3,7 +3,7 @@
import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag'
import { AppService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { operatorBuilderRights, userStore, workspaceStore } from '$lib/stores'
import { readFieldsRecursively } from '$lib/utils'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
@@ -258,7 +258,7 @@
// Seed the React 19 template so the editor has a usable state even if the
// user dismisses the picker without selecting.
const seedFiles = { ...react19Template }
const seedRunnables = { [STARTER_RUNNABLE_KEY]: STARTER_RUNNABLE }
const seedRunnables = starterRunnables()
savedApp = {
summary: '',
value: { files: seedFiles as any, runnables: seedRunnables as any },
@@ -484,9 +484,15 @@
redraw++
}
// The starter runnable is an inline script, which the backend refuses from an operator with
// builder rights: seeding it would make their very first deploy fail.
function starterRunnables() {
return $operatorBuilderRights ? {} : { [STARTER_RUNNABLE_KEY]: STARTER_RUNNABLE }
}
function onTemplatePickerStart(result: RawAppTemplatePickerResult, withPrompt: boolean) {
files = { ...result.files }
runnables = { ...result.runnables, [STARTER_RUNNABLE_KEY]: STARTER_RUNNABLE }
runnables = { ...result.runnables, ...starterRunnables() }
data = result.data
summary = result.summary
policy = result.policy
@@ -28,7 +28,13 @@
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import {
enterpriseLicense,
operatorBuilderRights,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
@@ -291,6 +297,10 @@
}
}
// Operators with builder rights author flows out of deployed runnables; every other operator
// is read-only here.
let canAuthorFlow = $derived(!$userStore?.operator || $operatorBuilderRights)
let moveDrawer: MoveDrawer | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let runForm: RunForm | undefined = $state()
@@ -298,7 +308,7 @@
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
const buttons: any = []
if (flow && !$userStore?.operator) {
if (flow && canAuthorFlow) {
buttons.push({
label: 'Fork',
buttonProps: {
@@ -353,11 +363,11 @@
}
})
if (!flow || $userStore?.operator || !can_write) {
if (!flow || !canAuthorFlow || !can_write) {
return buttons
}
if (!$userStore?.operator) {
if (canAuthorFlow) {
buttons.push({
label: 'Build app',
buttonProps: {
@@ -405,7 +415,7 @@
flow: Flow | undefined,
deployUiSettings: WorkspaceDeployUISettings | undefined
) {
if (!flow || $userStore?.operator) return []
if (!flow || !canAuthorFlow) return []
const menuItems: any = []
@@ -432,7 +442,7 @@
}
})
if (isDeployable('flow', flow?.path ?? '', deployUiSettings)) {
if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && !$userStore?.operator) {
menuItems.push({
label: 'Deploy to staging/prod',
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),