From 6e9e0de48146279737ea21150ce82b2bfa2c6d37 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 16 Aug 2026 21:06:08 +0000 Subject: [PATCH] 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) --- AGENTS.md | 3 + ...b84a779cac605acf6e88e3ae0c585303be31c.json | 22 ++ ...af063c36ac17e9d331cd9980b91b28d713c72.json | 22 -- ...f058e2634f8b9cd1019e277cad0120cf5fe3.json} | 4 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-api-flows/src/flows.rs | 79 ++++--- .../tests/native_triggers.rs | 64 ++++-- .../tests/operator_builder_rights.rs | 183 ++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 34 ++- backend/windmill-api/openapi.yaml | 3 + backend/windmill-api/src/apps.rs | 114 ++++++++-- backend/windmill-api/src/drafts.rs | 22 +- backend/windmill-api/src/jobs.rs | 25 ++- backend/windmill-common/src/ee_oss.rs | 8 + backend/windmill-common/src/flows.rs | 207 ++++++++++++++++++ backend/windmill-common/src/lib.rs | 46 +++- backend/windmill-common/src/workspaces.rs | 83 ++++++- .../src/github/routes.rs | 2 +- .../src/google/routes.rs | 6 +- backend/windmill-native-triggers/src/lib.rs | 10 +- .../src/nextcloud/routes.rs | 2 +- docs/operator-builder-rights.md | 65 ++++++ .../src/lib/components/FlowBuilder.svelte | 11 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 20 +- .../EmptyInlineScript.svelte | 36 +-- .../InlineScriptRunnableByPath.svelte | 24 +- .../mainInput/RunnableSelector.svelte | 10 +- .../lib/components/common/table/AppRow.svelte | 10 +- .../components/common/table/FlowRow.svelte | 18 +- .../copilot/chat/AIChatManager.svelte.ts | 16 +- .../flows/common/FlowCardHeader.svelte | 6 +- .../flows/content/FlowInputsQuick.svelte | 16 +- .../flows/content/FlowModuleComponent.svelte | 3 +- .../flows/map/InsertModuleInner.svelte | 13 +- .../components/home/CreateActionsMenu.svelte | 15 +- .../src/lib/components/home/ItemsList.svelte | 8 +- .../settings/WorkspaceOperatorSettings.svelte | 73 +++++- frontend/src/lib/stores.ts | 13 ++ .../src/routes/(root)/(logged)/+page.svelte | 4 +- .../apps_raw/edit/[...path]/+page.svelte | 12 +- .../(logged)/flows/get/[...path]/+page.svelte | 22 +- 41 files changed, 1120 insertions(+), 216 deletions(-) create mode 100644 backend/.sqlx/query-0d6a28270d740c4315ab20f6252b84a779cac605acf6e88e3ae0c585303be31c.json delete mode 100644 backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json rename backend/.sqlx/{query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json => query-f54d8959922435013239240db3f0f058e2634f8b9cd1019e277cad0120cf5fe3.json} (55%) create mode 100644 backend/windmill-api-integration-tests/tests/operator_builder_rights.rs create mode 100644 docs/operator-builder-rights.md diff --git a/AGENTS.md b/AGENTS.md index aab920746b..a6f8b81709 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/backend/.sqlx/query-0d6a28270d740c4315ab20f6252b84a779cac605acf6e88e3ae0c585303be31c.json b/backend/.sqlx/query-0d6a28270d740c4315ab20f6252b84a779cac605acf6e88e3ae0c585303be31c.json new file mode 100644 index 0000000000..a18466b539 --- /dev/null +++ b/backend/.sqlx/query-0d6a28270d740c4315ab20f6252b84a779cac605acf6e88e3ae0c585303be31c.json @@ -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" +} diff --git a/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json b/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json deleted file mode 100644 index 35b70a239c..0000000000 --- a/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json b/backend/.sqlx/query-f54d8959922435013239240db3f0f058e2634f8b9cd1019e277cad0120cf5fe3.json similarity index 55% rename from backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json rename to backend/.sqlx/query-f54d8959922435013239240db3f0f058e2634f8b9cd1019e277cad0120cf5fe3.json index 23cf739f90..812d8e2697 100644 --- a/backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json +++ b/backend/.sqlx/query-f54d8959922435013239240db3f0f058e2634f8b9cd1019e277cad0120cf5fe3.json @@ -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" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3ed75a93fc..c996f3e5a9 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f079db9e7962a413b349c4ff8036080894f30771 +9ba2f517be9f688d324bb5ad1e9379fac5fcc3ca diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 3c37523082..f51251fd87 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -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, + 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, @@ -555,11 +596,7 @@ async fn create_flow( Path(w_id): Path, Json(mut nf): Json, ) -> 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, ) -> Result { - 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, ) -> Result { - 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, ) -> Result { - 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( diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index c86ba3154f..2f6aad2f4e 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -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) -> 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, +) -> 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" ); } diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_rights.rs b/backend/windmill-api-integration-tests/tests/operator_builder_rights.rs new file mode 100644 index 0000000000..ba735f3703 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/operator_builder_rights.rs @@ -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, 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) -> 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(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9bc4890fe6..a2b379541b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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 { 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, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 06f167b108..db62870bdc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 037f701346..3cc4ace68d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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, Json(req): Json, ) -> Result { - 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, 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, Path((w_id, path)): Path<(String, StripPath)>, ) -> Result { - 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, ) -> Result { + // 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, Json(app): Json, ) -> 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 { - 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(), )); diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b44b0e64be..a179dcc1f0 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -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, Query(query): Query, ) -> Result>> { - // 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(), )); diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index b79e0b8b14..55e2c37cc7 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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 { 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, Json(raw_flow): Json, ) -> 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?; diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 2510df0960..82f99c54fb 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -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> { + Ok(None) +} + #[cfg(all(feature = "enterprise", not(feature = "private")))] pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result> { // Implementation is not open source diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 11ffd5da76..0626a9f9e3 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -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, 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, +) -> 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, +) -> 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| match node { + Some(_) => refuse("references code stored outside the flow"), + None => Ok(()), + }; + let mut push_tag = |tag: &Option| { + 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, +) -> 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()); + } + } } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index b72d0ae1d9..b736cf527d 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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, &[]))); + } +} diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 7ba930c3ea..79d9164f4b 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -762,7 +762,8 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result { /// 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 { .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 = 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 { + 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 { + Ok(is_operator && !operator_builder_enabled(db, workspace_id).await?) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleCheckResult { Allowed, diff --git a/backend/windmill-native-triggers/src/github/routes.rs b/backend/windmill-native-triggers/src/github/routes.rs index 68de93b6a9..3983582318 100644 --- a/backend/windmill-native-triggers/src/github/routes.rs +++ b/backend/windmill-native-triggers/src/github/routes.rs @@ -21,7 +21,7 @@ async fn list_repos( Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { - 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(); diff --git a/backend/windmill-native-triggers/src/google/routes.rs b/backend/windmill-native-triggers/src/google/routes.rs index 306a7e54e2..1cfc204bb8 100644 --- a/backend/windmill-native-triggers/src/google/routes.rs +++ b/backend/windmill-native-triggers/src/google/routes.rs @@ -93,7 +93,7 @@ async fn list_calendars( Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { - 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, Query(query): Query, ) -> JsonResult { - 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, Path(workspace_id): Path, ) -> JsonResult> { - 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!( diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 4400091ed6..11e2018348 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -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(), )); diff --git a/backend/windmill-native-triggers/src/nextcloud/routes.rs b/backend/windmill-native-triggers/src/nextcloud/routes.rs index a1c679602c..3fac101901 100644 --- a/backend/windmill-native-triggers/src/nextcloud/routes.rs +++ b/backend/windmill-native-triggers/src/nextcloud/routes.rs @@ -21,7 +21,7 @@ async fn list_available_events( Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { - 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 diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md new file mode 100644 index 0000000000..3f69619b63 --- /dev/null +++ b/docs/operator-builder-rights.md @@ -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. diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 52af60d7a7..330b71c1ba 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -19,6 +19,7 @@ } from '$lib/components/flows/linkedAgentToolsStore.svelte' import { enterpriseLicense, + operatorBuilderRights, userStore, userWorkspaces, workspaceStore, @@ -1356,7 +1357,7 @@ {#key renderCount} - {#if !$userStore?.operator} + {#if !$userStore?.operator || $operatorBuilderRights} {#if $pathStore} {/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} {:else} - Flow Builder not available to operators +
+ Flow builder not available to operators +
{/if} {/key} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index f673711159..addfbb516e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -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 @@
{ 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} +
+ Required for apps built by operators, and cannot be turned off. +
+ {/if}
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 diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte index 214ab3e31d..6496c7e17a 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte @@ -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 @@ - + {#if !$operatorBuilderRights} + + {/if}
@@ -130,21 +132,25 @@ id="app-editor-empty-runnable" >
-
Choose a language
+
+ {$operatorBuilderRights ? 'Choose a script or flow' : 'Choose a language'} +
{#if showScriptPicker} {/if} - + {#if !$operatorBuilderRights} + + {/if}
-
+ +
Backend
diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte index 3089809f12..9c7676120f 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte @@ -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 - + {#if !$operatorBuilderRights} + + {/if} {/if} {/if} - {#if !onlyFlow} - + {#if !$operatorBuilderRights} + {#if !onlyFlow} + + {/if} + {/if} -
diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 636bd2c9d6..a179faeb43 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -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 } ] }} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index a155ca3a97..9614e00b05 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -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 } ] }} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 60d77ef506..c582f1816a 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -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 = $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, diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 8c9e57fa10..218824e03c 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -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, diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index cf9d008a27..489cfcaab1 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -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'}
Integrations
{/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'}
Hub
diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index d1fdf8f412..4effcfdf10 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -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( diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 0b3e77a848..ae37538cde 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -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 @@
dispatch('close')} - {disableAi} + disableAi={disableAi || $operatorBuilderRights} on:insert bind:funcDesc {preFilter} {loading} /> - {#if selectedKind != 'preprocessor' && selectedKind != 'flow'} + {#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !$operatorBuilderRights} {/if} {/if} - {#if customUi?.aiSandbox != false} + {#if customUi?.aiSandbox != false && !$operatorBuilderRights} 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 @@ {/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'} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index d7717d1e04..045a49bee6 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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. diff --git a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte index ba8f42cba7..9bc032df73 100644 --- a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte @@ -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()}