feat: let operators compose flows when the workspace grants the right (#11228)

* feat: let a workspace withdraw operator schedule and trigger writes

Operators can create, edit and delete schedules and triggers today through the
API, CLI and MCP, while the operator_settings flags beside them only hide those
pages. An admin who wants operators to see what is scheduled without letting
them change it cannot express that. Add manage_schedules and manage_triggers as
enforced settings, gated at the schedule handlers and at the generic TriggerCrud
routes so every trigger kind is covered by one check.

They name capabilities operators already hold, so they are granted unless
withdrawn, and absence has to mean "never configured" rather than a value. The
read coalesces to true; the update endpoint merges into the stored jsonb with
the two fields as Option<bool>, so an omitted key keeps what is stored.
operator_settings is git-synced as a whole object, so a settings file written
before these keys existed reaches the endpoint on every pull, and a serde or SQL
default of either polarity would turn that pull into a silent withdrawal or
restoration.

The rights are read through a per-process cache, so withdrawing one publishes a
notify_event that drops the entry on every replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4

* feat: let operators compose flows when the workspace grants the right

Adds operator_settings.builder_flows: a workspace setting that lets every
operator compose flows 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 this does not move it: check_flow_is_composition_only walks
the value and refuses anything carrying code, including the shapes an obvious
walk misses (code hoisted into a flow_node, an AI agent step's tools, and a
linked ai_agent resource whose tool list is resolved at run time).

What the walk cannot settle it returns for the caller to authorize under RLS:
the worker tags the steps pin, every runnable they reference, and the (path,
hash) of every version-pinned step. Composing a path is enough to run it and to
run it as whoever it runs as, since the worker resolves a step's path with the
root DB handle and adopts that runnable's on_behalf_of. A pinned hash needs its
own check because dispatch ignores the path beside it.

The gate runs on every write and on both request-supplied-value paths, flow
preview and flow dependencies, or either becomes the way to run what the write
path refuses.

Operators of a builder workspace consume a full author seat; the EE companion
carries the counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4

* feat: enforce operator write rights on the router and in the UI

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close the capture gap and gate the trigger editors' write actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate acl writes and the native trigger drawer behind manage rights

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse operator writes with 403 and gate sharing at the drawer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: resolve identity in the operator write gate only for writes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate the suspended-jobs actions and stop the route check refusing reads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: explain the empty-state create button when operator writes are withdrawn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: audit operator settings changes and fold path writes into native rows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: open locked editors read-only and group the operator settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: skip email and azure lookups on editor open while triggers are locked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: state each operator-rights rationale once in comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address CI review findings on operator write rights

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep capture move gated and skip it in the builders while locked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: refuse builder-rights violations with 403 so operators stay logged in

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: trim duplication in the operator builder gates

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: hide build app from builder operators on the flow page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: point at the companion EE PR merged with EE main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep a builder's drafts list loading past drafts they cannot write

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: hide saved agents from builder operators in the step picker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: note the inlined seat rule and drop orphaned sqlx entries

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: stop a builder's step test from logging them out

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: refuse a builder's dependency job on a path it cannot write

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep builders from adding dynamic dropdown code to a flow

A flow's dropdown code runs as whoever loads its form, so a builder may keep or drop the code stored on the flow it updates, never add or change it. The builder's editor hides the dropdown types and code, and previews options through the deployed flow; the inline dropdown refusal is a 403 so it no longer logs operators out. Also trims rationale comments repeated across sites.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: show why saving operator settings failed

The seat-cap refusal on granting builder rights explains what to do; the toast now carries the server's message instead of a generic failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: check builder flow drafts like deploys and treat dropdown code as code

A developer who loads a builder's flow draft in the editor runs its dynamic dropdown code as themselves, so a builder's draft now passes the same checks as a deploy. Dropdown code is refused like step code rather than kept or dropped, which also removes the exact-match comparison that refused builders over whitespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: bill a builder workspace's operators as developers on cloud

The cloud seat count behind the Premium page, the sidebar usage and the fork cap still weighed every operator at half a seat, while the builder right makes them authors. The out-of-repo invoicing job must follow the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: check a builder's flow draft as it will be stored

Draft storage strips NUL escapes after the builder check, so a key ending in one (value\u0000, x-windmill-dyn-select-code\u0000) passed the check as an unknown field and was stored under its plain name. The check now reads the sanitized text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: list a builder's flow drafts and hide hub imports from builders

A builder's undeployed flows now appear in the home list, the flow list and the folder counts. Hub project imports and templates bring scripts and apps along, so builders are no longer offered them. The docs record builders' JavaScript expressions as an accepted risk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the stored builder right when a settings payload omits it

A git-synced settings file written before the key existed withdrew the right on every push. builder_flows now follows the manage_* rights: an omitted key leaves the stored value, and the CLI does not count it as a difference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: word builder refusals by what the flow contains, test the tag refusal

A builder refused on a developer's flow never changed its code, so the refusals now describe the flow ("has inline code, so only a developer can edit this flow") rather than an authoring attempt. The grant confirmation uses the neutral dialog: granting changes billing but destroys nothing. The integration test pins the refusal of a worker tag the workspace cannot use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: read builder rights from the operating workspace, gate the flow page's audit logs entry

Builder rights now come from useOperatorBuilderFlows(), next to the schedule and trigger locks, so an editor embedded for another workspace answers about that workspace; the legacy AI chat, one instance for the whole app, reads the navigation workspace. The flow page's Audit logs entry follows the operator audit_logs setting now that builders open that menu. Operator settings reset every value on load, null settings included, so nothing carries over from the previous workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: pick a dynamic dropdown's code source by the operating user's role

The flow input editor, the flow test panel and the flow chat send the dropdown request to the operating workspace, so they now also choose inline versus deployed code by the role held there, through useOperatingUser(), instead of the navigation workspace's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6

This commit updates the EE repository reference after PR #815 was merged in windmill-ee-private.

Previous ee-repo-ref: 31c9e66884b8ca805b20bbfad41fc428fbedbc0e

New ee-repo-ref: 40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
AlexRV12
2026-09-30 18:09:14 +02:00
committed by GitHub
co-authored by Claude Opus 5 windmill-internal-app[bot]
parent 9613549abc
commit ca44043e12
42 changed files with 1464 additions and 235 deletions
+2
View File
@@ -37,6 +37,8 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
fields and a jsonb merge rather than a serde default
- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace setting that lets
operators compose flows, what the composition check must cover, and why it costs a full seat
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\",\n COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",\n COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"\n FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flows!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "schedules!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "triggers!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde"
}
@@ -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 active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\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 active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\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_flows')::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": "4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a"
"hash": "cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b"
}
+1 -1
View File
@@ -1 +1 @@
5a2b6b8527250bd12cf856d8a667a9ef3106ec60
40ac1c5f8cbce3843b582d9b392d3f3cc7eca3e6
+9 -9
View File
@@ -18,7 +18,7 @@
//!
//! This test pins down:
//! - an Operator's own token is rejected by the operator guard (the core fix;
//! pre-fix this reached the inline executor instead of returning 401),
//! pre-fix this reached the inline executor instead of returning 403),
//! - a regular non-operator passes the guard (the fix must not over-block the
//! legitimate inline preview flow): in the test harness the worker inline
//! utils are not registered, so a caller past the guard gets the distinct
@@ -121,10 +121,10 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
// 1. CORE REGRESSION: an Operator must be rejected by the operator guard.
// Pre-fix this fell through to the inline executor (arbitrary code
// execution); post-fix it returns 401 with the operator guard message.
// execution); post-fix it returns 403 with the operator guard message.
let (status, body) = post(&url, "OPERATOR_TOKEN", &inline_preview_body()).await;
assert_eq!(
status, 401,
status, 403,
"Operator must be rejected from inline preview (got {status}): {body}"
);
assert!(
@@ -139,7 +139,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
// the operator guard did not reject it.
let (status, body) = post(&url, "SECRET_TOKEN_2", &inline_preview_body()).await;
assert_ne!(
status, 401,
status, 403,
"non-operator must not be blocked by the operator guard (got {status}): {body}"
);
assert!(
@@ -155,7 +155,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
operator_job_token(uuid::Uuid::parse_str(RUNNING_JOB_ID).unwrap()).await;
let (status, body) = post(&url, &running_job_token, &datatable_query_body()).await;
assert_ne!(
status, 401,
status, 403,
"operator job token of a running job must pass the guard for a datatable query (got {status}): {body}"
);
assert!(
@@ -188,7 +188,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
] {
let (status, body) = post(&url, &running_job_token, &payload).await;
assert_eq!(
status, 401,
status, 403,
"operator job token must be rejected for a {label} payload (got {status}): {body}"
);
assert!(
@@ -208,7 +208,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
let token = operator_job_token(job_id).await;
let (status, body) = post(&url, &token, &datatable_query_body()).await;
assert_eq!(
status, 401,
status, 403,
"operator job token of a {label} job must be rejected (got {status}): {body}"
);
assert!(
@@ -229,7 +229,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
);
let (status, body) = post(&fallback_url, "OPERATOR_TOKEN", &datatable_query_body()).await;
assert_eq!(
status, 401,
status, 403,
"Operator must be rejected from the preview fallback (got {status}): {body}"
);
assert!(
@@ -246,7 +246,7 @@ async fn test_inline_preview_authorization(db: Pool<Postgres>) -> anyhow::Result
let deferred_url = format!("{fallback_url}?{deferral}");
let (status, body) = post(&deferred_url, &running_job_token, &datatable_query_body()).await;
assert_eq!(
status, 401,
status, 403,
"operator job token must not schedule a deferred preview with {deferral} (got {status}): {body}"
);
assert!(
+150 -28
View File
@@ -16,10 +16,12 @@ 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, check_operator_can_build_flows, operator_can_build_flows, RuleCheckResult,
};
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
use windmill_common::{
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
utils::HTTP_CLIENT,
@@ -35,7 +37,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,
@@ -241,7 +243,7 @@ async fn list_flows(
// Append the authed user's drafts at paths with no deployed flow; see scripts.rs.
if lq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?)
&& offset == 0
&& lq.path_start.is_none()
&& lq.path_exact.is_none()
@@ -570,7 +572,13 @@ async fn list_paths_linking_agent(
Ok(Json(flows))
}
async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
async fn validate_flow(
new_flow: &NewFlow,
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
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(
@@ -581,9 +589,139 @@ async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
guard_flow_from_debounce_data(new_flow).await?;
if authed.is_operator {
validate_operator_flow(
&new_flow.parse_flow_value()?,
&new_flow.tag,
new_flow.schema.as_ref().map(|s| s.0.get()),
authed,
db,
user_db,
w_id,
)
.await?;
}
return Ok(());
}
/// What an operator with builder rights must pass to store a flow, deployed or as a draft: a
/// developer who loads a builder's draft in the editor runs its code as themselves.
pub async fn validate_operator_flow(
value: &FlowValue,
flow_tag: &Option<String>,
schema: Option<&str>,
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
) -> error::Result<()> {
// Dynamic dropdown code runs as whoever loads the flow's form: it is code like a step's.
if let Some(schema) = schema {
let schema: serde_json::Value = serde_json::from_str(schema)?;
if schema.get("x-windmill-dyn-select-code").is_some() {
return Err(Error::PermissionDenied(
"This flow has dynamic dropdown code, so only a developer can edit it".to_string(),
));
}
}
validate_operator_composed_flow(value, flow_tag, authed, db, user_db, w_id).await
}
/// Runs on every write and every preview of a flow authored by an operator with builder rights.
/// The walk in `check_flow_is_composition_only` only sees the value; what it collects is
/// authorized here against the caller's own permissions.
pub async fn validate_operator_composed_flow(
value: &FlowValue,
flow_tag: &Option<String>,
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
) -> error::Result<()> {
let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?;
// A tag is how a step picks the worker group it runs on: unauthorized, a builder could route
// a job onto a privileged one.
refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty()));
if !refs.tags.is_empty() {
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
for tag in &refs.tags {
windmill_common::jobs::check_tag_available_for_workspace_internal(
db,
w_id,
tag,
None,
std::future::ready(w_id.to_string()),
is_super_admin,
get_scope_tags(authed),
)
.await?;
}
}
if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() {
return Ok(());
}
// A flow can step through the same script thirty times; this runs on every write, preview and
// dependency job.
refs.runnables.sort();
refs.runnables.dedup();
refs.pinned_scripts
.sort_by_key(|(path, hash)| (path.clone(), hash.0));
refs.pinned_scripts
.dedup_by_key(|(path, hash)| (path.clone(), hash.0));
// The worker resolves a step's path with the root DB handle and runs it as that runnable's
// `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS
// on this transaction is the check.
let mut tx = user_db.clone().begin(authed).await?;
for (is_flow, path) in &refs.runnables {
let readable = if *is_flow {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
w_id,
path,
)
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)",
w_id,
path,
)
}
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if !readable {
return Err(Error::PermissionDenied(format!(
"{} {path} does not exist or is not readable by you",
if *is_flow { "Flow" } else { "Script" }
)));
}
}
// A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable
// path paired with another script's hash would still run that other script.
for (path, hash) in &refs.pinned_scripts {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)",
w_id,
path,
hash.0,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::PermissionDenied(format!(
"Version {hash} is not a readable version of {path}"
)));
}
}
tx.commit().await?;
Ok(())
}
async fn create_flow(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -592,11 +730,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_flows(&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.
@@ -616,7 +750,7 @@ async fn create_flow(
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf).await?;
validate_flow(&nf, &authed, &db, &user_db, &w_id).await?;
if *CLOUD_HOSTED {
let nb_flows =
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
@@ -1187,11 +1321,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_flows(&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);
@@ -1218,7 +1348,7 @@ async fn update_flow(
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf).await?;
validate_flow(&nf, &authed, &db, &user_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?;
@@ -1855,11 +1985,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_flows(&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(
@@ -2000,11 +2126,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_flows(&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(
@@ -0,0 +1,317 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_rights_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>, flows: bool) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2",
)
.bind(format!(r#"{{"builder_flows": {flows}}}"#))
.bind(WS)
.execute(db)
.await?;
// The right is read through a process-global 60s cache keyed by workspace id.
invalidate_operator_rights_cache(WS);
Ok(())
}
async fn add_script(db: &Pool<Postgres>, hash: i64, path: &str, owner: &str) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema,
summary, description, lock, extra_perms)
VALUES ($1, $2, $3, 'x', 'bun', 'script', $4, '{}', '', '', '', '{}')",
)
.bind(WS)
.bind(hash)
.bind(path)
.bind(owner)
.execute(db)
.await?;
Ok(())
}
fn composition_flow_at(path: &str, step_path: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "",
"description": "",
"schema": {},
"value": {"modules": [{
"id": "a",
"value": {"type": "script", "path": step_path, "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: the builder right lets an operator compose runnables that are
/// already deployed and nothing more, and the endpoints that author code stay shut whether or not
/// it is granted.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_operator_builder_flows_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();
// A composition-only flow references a runnable that exists and the builder can read, so the
// fixture needs one.
add_script(&db, 4241, "u/operator/some_script", "operator").await?;
set_builder(&db, false).await?;
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow_at(
"u/operator/f1",
"u/operator/some_script",
))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"an operator without the builder right must not create a flow"
);
set_builder(&db, true).await?;
// A payload that omits the key, like a git-sync file that predates it, keeps the right.
let resp = reqwest::Client::new()
.post(format!("{api}/workspaces/operator_settings"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"runs": true}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow_at(
"u/operator/f1",
"u/operator/some_script",
))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to create a composition-only flow: {}",
resp.text().await?
);
let mut tagged = composition_flow_at("u/operator/f5", "u/operator/some_script");
tagged["tag"] = json!("privileged_group");
let resp = c
.post(format!("{api}/flows/create"))
.json(&tagged)
.send()
.await?;
assert_eq!(
resp.status(),
400,
"a builder must not route a flow onto a worker tag the workspace cannot use"
);
let mut with_dyn_code = composition_flow_at("u/operator/f1", "u/operator/some_script");
with_dyn_code["schema"] =
json!({"x-windmill-dyn-select-code": "x", "x-windmill-dyn-select-lang": "bun"});
let resp = c
.post(format!("{api}/flows/update/u/operator/f1"))
.json(&with_dyn_code)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not save a flow carrying dropdown code"
);
let resp = c
.post(format!("{api}/jobs/run/dynamic_select"))
.json(
&json!({"entrypoint_function": "f", "runnable_ref": {"source": "inline", "code": "x"}}),
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"an operator's inline dropdown refusal must not be a 401"
);
// A dependency job rewrites bookkeeping stored under the path it names, so a builder must not
// aim one at a path it cannot write.
let resp = c
.post(format!("{api}/jobs/run/flow_dependencies"))
.json(&json!({"path": "u/alice/private_flow", "flow_value": {"modules": []}}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not run a dependency job on a path it cannot write"
);
// A legacy draft has no owner, so it lists as the builder's own, and a script draft is one
// the builder cannot write: that row must read as not writable, not fail the whole list.
sqlx::query(
"INSERT INTO draft (workspace_id, path, typ, value) VALUES ($1, 'u/operator/some_script', 'script', '{}')",
)
.bind(WS)
.execute(&db)
.await?;
let resp = c.get(format!("{api}/drafts/list")).send().await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let drafts: serde_json::Value = resp.json().await?;
assert_eq!(drafts[0]["can_write"], false, "{drafts}");
let resp = c
.post(format!("{api}/flows/create"))
.json(&inline_code_flow("u/operator/f2"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not deploy a flow carrying inline code"
);
// Draft storage strips a NUL, turning these keys into the plain ones the check reads.
let mut nul_value = inline_code_flow("u/operator/f1");
let v = nul_value.as_object_mut().unwrap().remove("value").unwrap();
nul_value["value\0"] = v;
let mut nul_dyn = composition_flow_at("u/operator/f1", "u/operator/some_script");
nul_dyn["schema"] = json!({"x-windmill-dyn-select-code\0": "x"});
for (draft, expected) in [
(
composition_flow_at("u/operator/f1", "u/operator/some_script"),
200,
),
(inline_code_flow("u/operator/f1"), 403),
(with_dyn_code.clone(), 403),
(nul_value, 403),
(nul_dyn, 403),
] {
let resp = c
.post(format!("{api}/drafts/update/flow/u/operator/f1"))
.json(&json!({ "value": draft, "force": true }))
.send()
.await?;
let status = resp.status();
assert_eq!(
status,
expected,
"flow draft {draft}: {}",
resp.text().await?
);
}
// 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_eq!(
resp.status(),
403,
"a builder must not preview a flow carrying inline code"
);
// Authoring code directly stays shut with the right 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"
);
// `permissions_test` gives the operator fixture no rights on `u/alice/**`.
add_script(&db, 4243, "u/alice/private", "alice").await?;
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow_at("u/operator/f4", "u/alice/private"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not compose a runnable it cannot read"
);
add_script(&db, 4242, "u/operator/pinned", "operator").await?;
let pinned = |hash: &str| {
json!({
"path": "u/operator/f3", "summary": "", "description": "", "schema": {},
"value": {"modules": [{
"id": "a",
"value": {
"type": "script", "path": "u/operator/pinned", "hash": hash,
"input_transforms": {}
}
}]}
})
};
let resp = c
.post(format!("{api}/flows/create"))
.json(&pinned("0000000000000000"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not pin a hash that is not a version of the step's path"
);
let resp = c
.post(format!("{api}/flows/create"))
.json(&pinned("0000000000001092"))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to pin the real version of a readable script: {}",
resp.text().await?
);
Ok(())
}
@@ -3471,7 +3471,6 @@ fn apply_pg_tls_env(
Ok(None)
}
#[cfg(test)]
mod pg_tls_env_tests {
use super::apply_pg_tls_env;
@@ -4242,7 +4241,9 @@ async fn edit_datatable_config(
// the other managed cluster, one whose role ids name nothing in that cluster's catalog.
// Refuse it instead: turning roles off first is one step, and it keeps discarding an access
// decision something somebody chose rather than a side effect of moving a database.
let old_kind = old.and_then(|old| old.database.as_ref()).map(|d| d.resource_type);
let old_kind = old
.and_then(|old| old.database.as_ref())
.map(|d| d.resource_type);
if dt.permissions.is_some()
&& dt
.database
@@ -10871,8 +10872,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));
}
@@ -11025,8 +11030,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));
}
@@ -11589,10 +11598,15 @@ struct ChangeOperatorSettings {
folders: bool,
#[serde(default)]
workers: bool,
/// Writes operators may perform unless withdrawn, so `None` (key absent) must mean "leave as
/// stored" rather than a value: the row is merged, not overwritten, and this endpoint takes
/// whole-object payloads from git-sync files that predate the key. Defaulting either way here
/// would make an older file silently withdraw or restore the right on every pull.
/// Write rights, so `None` (key absent) must mean "leave as stored" rather than a value: the
/// row is merged, not overwritten, and this endpoint takes whole-object payloads from git-sync
/// files that predate the key. Defaulting either way here would make an older file silently
/// withdraw or grant the right on every push.
///
/// `builder_flows` lets every operator of this workspace compose flows out of already-deployed
/// runnables, and makes each of them consume a full author seat instead of half of one.
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_flows: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -11607,6 +11621,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. It is a no-op delta when the right is already on.
#[cfg(feature = "enterprise")]
if settings.builder_flows == Some(true) {
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);
+3
View File
@@ -37549,6 +37549,9 @@ components:
workers:
type: boolean
description: Whether operators can view workers page
builder_flows:
type: boolean
description: Whether operators can compose flows out of existing runnables (consumes a full seat). Omitting the field leaves the stored value unchanged.
manage_schedules:
type: boolean
description: Whether operators can create, edit and delete schedules. Granted unless withdrawn; omitting the field leaves the stored value unchanged.
+55 -17
View File
@@ -14,13 +14,16 @@ use axum::{
Json, Router,
};
use serde::{Deserialize, Serialize};
use windmill_api_flows::flows::validate_operator_flow;
use windmill_common::{
db::UserDB,
error::{Error, Result},
flows::FlowValue,
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
users::resolve_username_to_email,
utils::{check_proper_path, strip_json_nul},
variables::{build_crypt, encrypt},
workspaces::operator_can_build_flows,
};
pub fn workspaced_service() -> Router {
@@ -101,10 +104,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_can_build_flows(&db, &w_id).await? {
return Ok(Json(vec![]));
}
let all_users = query.all_users.unwrap_or(false);
@@ -154,10 +157,13 @@ async fn list_drafts(
{
Ok(()) => true,
// A stored draft can sit at an unwritable path — unauthorized,
// or malformed (`BadRequest`; the `draft` table has no path
// constraint). Either way it's not writable, and one bad row
// must not 400 the whole listing.
Err(Error::NotAuthorized(_)) | Err(Error::BadRequest(_)) => false,
// refused to an operator (`PermissionDenied`), or malformed
// (`BadRequest`; the `draft` table has no path constraint).
// Either way it's not writable, and one bad row must not fail
// the whole listing.
Err(Error::NotAuthorized(_))
| Err(Error::PermissionDenied(_))
| Err(Error::BadRequest(_)) => false,
Err(e) => return Err(e),
};
out.push(row);
@@ -464,6 +470,32 @@ async fn update_draft(
}
}
if authed.is_operator && kind == UserDraftItemKind::Flow {
if let Some(value) = &req.value {
#[derive(Deserialize)]
struct FlowDraft {
#[serde(default)]
value: FlowValue,
schema: Option<Box<serde_json::value::RawValue>>,
tag: Option<String>,
}
// The text stored below, NULs stripped: a stripped NUL can rename a key into one
// this check reads.
let draft: FlowDraft = serde_json::from_str(&strip_json_nul(value.0.get()))
.map_err(|e| Error::BadRequest(format!("Invalid flow draft: {e}")))?;
validate_operator_flow(
&draft.value,
&draft.tag,
draft.schema.as_deref().map(|s| s.get()),
&authed,
&db,
&user_db,
&w_id,
)
.await?;
}
}
let applied = if let Some(value) = &req.value {
// Secret variable values must never sit in `draft.value` in plaintext
// (see `encrypt_secret_variable_value`).
@@ -1111,13 +1143,14 @@ fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> {
}
/// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are
/// rejected outright. Two layers:
/// rejected, except for flow drafts in a workspace that granted them builder
/// rights. Two layers:
/// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable
/// `f/`) — mirror what RLS reads from the same JWT claims, and are the
/// ENTIRE check for draft-only paths (no deployed row for RLS to use).
/// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for
/// what the path can't answer, above all item-level extra_perms grants.
async fn require_can_write_path(
pub(crate) async fn require_can_write_path(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
@@ -1128,14 +1161,19 @@ 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.
// Operators are read-only and never WRITE drafts, except a flow draft where the workspace
// granted the builder right: the kind has to be checked, or the right would open drafts of
// kinds it says nothing about. 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 {
return Err(Error::NotAuthorized(
"operators cannot save drafts".to_string(),
));
let granted =
matches!(kind, UserDraftItemKind::Flow) && operator_can_build_flows(db, w_id).await?;
if !granted {
return Err(Error::PermissionDenied(
"operators cannot save drafts".to_string(),
));
}
}
// Cheap claim-based namespace checks first: they evaluate the same JWT
// claims RLS reads, so the outcome matches the policies while sparing the
+47 -15
View File
@@ -26,6 +26,7 @@ use std::time::Instant;
use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use url::Url;
use windmill_api_flows::flows::validate_operator_composed_flow;
use windmill_common::assets::AssetUsageAccessType;
use windmill_common::auth::TOKEN_PREFIX_LEN;
#[cfg(feature = "run_inline")]
@@ -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_flows, 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};
@@ -8600,7 +8603,9 @@ fn operator_preview_refusal(job_id: Option<Uuid>) -> error::Error {
} else {
"Operators cannot run preview jobs for security reasons"
};
error::Error::NotAuthorized(reason.to_string())
// 403, not 401: the frontend reads a 401 as a dead session and logs the user out, and the
// flow editor builders open reaches this route.
error::Error::PermissionDenied(reason.to_string())
}
async fn run_preview_script(
@@ -9282,14 +9287,32 @@ pub struct RunFlowDependenciesResponse {
async fn push_flow_dependencies_job(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
req: RunFlowDependenciesRequest,
) -> error::Result<Uuid> {
check_scopes(authed, || format!("jobs:run"))?;
check_operator_can_build_flows(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, user_db, w_id).await?;
// The job rewrites bookkeeping stored under `req.path` (dependency map, asset usages,
// lock error) even with `skip_flow_update`, so a builder must be able to write there.
crate::drafts::require_can_write_path(
authed,
db,
user_db,
w_id,
windmill_common::user_drafts::UserDraftItemKind::Flow,
&req.path,
)
.await
.map_err(|e| match e {
error::Error::NotAuthorized(msg) => error::Error::PermissionDenied(msg),
e => e,
})?;
}
if req.raw_deps.is_some() {
@@ -9355,20 +9378,22 @@ async fn push_flow_dependencies_job(
async fn run_flow_dependencies_job(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<RunFlowDependenciesRequest>,
) -> error::Result<Response> {
let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?;
let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?;
run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await
}
async fn run_flow_dependencies_job_async(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<RunFlowDependenciesRequest>,
) -> error::Result<(StatusCode, String)> {
let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?;
let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?;
Ok((StatusCode::CREATED, uuid.to_string()))
}
@@ -9669,15 +9694,24 @@ 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_flows(&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,
&user_db,
&w_id,
)
.await?;
}
// Restarting copies the source runs' step results into the new run, and the queue resolves
// them with the service pool, so every run the request names must be readable as the caller.
let mut level = raw_flow.restarted_from.as_ref();
@@ -9811,9 +9845,7 @@ async fn run_dynamic_select(
DynamicSelectRunnableRef::Inline { .. }
) && authed.is_operator
{
return Err(error::Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
return Err(operator_preview_refusal(None));
}
if !is_valid_entrypoint_name(&request.entrypoint_function) {
+14 -5
View File
@@ -34,6 +34,7 @@ use std::collections::HashMap;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
workspaces::operator_can_build_flows,
};
use windmill_types::scripts::ScriptHash;
use windmill_types::user_drafts::DraftUserRef;
@@ -397,7 +398,7 @@ fn draft_branch_sql(kind: &str) -> String {
async fn list_runnables(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(_db): Extension<DB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(q): Query<ListRunnablesQuery>,
) -> JsonResult<ListRunnablesResponse> {
@@ -571,9 +572,9 @@ async fn list_runnables(
// Draft-only rows are the caller's own work in progress: never archived, so they
// have no place in the archived view, and carrying no labels of their own they are
// out of scope of a label filter (as in the per-kind endpoints). Operators don't
// see other people's drafts and have none of their own to see.
// see other people's drafts and have none of their own, except a builder's flows.
let include_drafts = q.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?)
&& !show_archived
&& q.label.as_ref().filter(|s| !s.is_empty()).is_none();
let draft_extras_for = |kind: &str| -> Vec<String> {
@@ -659,7 +660,7 @@ async fn list_runnables(
keyset: Option<&str>,
limit: Option<usize>|
-> Option<String> {
if !include_drafts || !kinds.contains(&kind) {
if !include_drafts || !kinds.contains(&kind) || (authed.is_operator && kind != "flow") {
return None;
}
// `fav` is ignored: with no favorite join there is nothing to filter on, and the
@@ -1004,9 +1005,17 @@ async fn add_draft_counts(
q: &CountRunnablesQuery,
counts: &mut HashMap<String, i64>,
) -> Result<(), Error> {
if !q.include_draft_only.unwrap_or(false) || authed.is_operator {
if !q.include_draft_only.unwrap_or(false) {
return Ok(());
}
// An operator has no drafts of their own, except a builder's flows.
let kinds: &[&str] = if !authed.is_operator {
kinds
} else if kinds.contains(&"flow") && operator_can_build_flows(db, w_id).await? {
&["flow"]
} else {
return Ok(());
};
// $1 = workspace, $2 = the caller's email.
let mut binds: Vec<String> = vec![];
let branches: Vec<String> = kinds
+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
+240
View File
@@ -17,6 +17,7 @@ use crate::{
cache::{self, FlowExtras},
db::DB,
error::{to_anyhow, Error},
scripts::ScriptHash,
utils::{http_get_from_hub, StripPath},
worker::{to_raw_value, Connection},
DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION,
@@ -246,6 +247,156 @@ 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 what the caller still has to authorize against its own permissions, which this
/// value-only walk cannot: every runnable the steps reference, the worker tags they pin, and the
/// `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not
/// already settled by the walk.
pub fn check_flow_is_composition_only(value: &FlowValue) -> Result<ComposedFlowRefs, Error> {
let mut refs = ComposedFlowRefs::default();
for module in value
.modules
.iter()
.chain(value.preprocessor_module.as_deref())
.chain(value.failure_module.as_deref())
{
check_module_is_composition_only(module, &mut refs)?;
}
Ok(refs)
}
/// What [`check_flow_is_composition_only`] collects for the caller to authorize.
#[derive(Default)]
pub struct ComposedFlowRefs {
pub tags: Vec<String>,
/// Every workspace runnable a step references, as `(is_flow, path)`.
pub runnables: Vec<(bool, String)>,
/// Version-pinned script steps, as `(path, hash)`.
pub pinned_scripts: Vec<(String, ScriptHash)>,
}
fn check_module_is_composition_only(
module: &FlowModule,
refs: &mut ComposedFlowRefs,
) -> 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, refs)
}
fn check_module_value_is_composition_only(
value: &FlowModuleValue,
id: &str,
refs: &mut ComposedFlowRefs,
) -> Result<(), Error> {
let refuse = |what: &str| Err(refused(id, what));
// A node id points at code in a `flow_node` row, which only the dependency job produces by
// hoisting a step's code, so an authored value carrying one names another flow's code and slips
// past this walk. Editor payloads come 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()) {
refs.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, hash, tag_override, .. } => {
check_composable_path(path, id)?;
push_tag(tag_override);
refs.runnables.push((false, path.clone()));
if let Some(hash) = hash {
refs.pinned_scripts.push((path.clone(), *hash));
}
}
FlowModuleValue::Flow { path, .. } => {
check_composable_path(path, id)?;
refs.runnables.push((true, path.clone()));
}
FlowModuleValue::ForloopFlow { modules, modules_node, .. }
| FlowModuleValue::WhileloopFlow { modules, modules_node, .. } => {
refuse_node(modules_node)?;
for module in modules {
check_module_is_composition_only(module, refs)?;
}
}
FlowModuleValue::BranchOne { branches, default, default_node } => {
refuse_node(default_node)?;
for module in default {
check_module_is_composition_only(module, refs)?;
}
check_branches_are_composition_only(branches, id, refs)?;
}
FlowModuleValue::BranchAll { branches, .. } => {
check_branches_are_composition_only(branches, id, refs)?
}
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, refs)?;
}
}
}
}
Ok(())
}
fn check_branches_are_composition_only(
branches: &[Branch],
id: &str,
refs: &mut ComposedFlowRefs,
) -> Result<(), Error> {
for branch in branches {
if branch.modules_node.is_some() {
return Err(refused(
id,
"has a branch that references code stored outside the flow",
));
}
for module in &branch.modules {
check_module_is_composition_only(module, refs)?;
}
}
Ok(())
}
fn refused(id: &str, what: &str) -> Error {
Error::PermissionDenied(format!(
"Step {id} {what}, so only a developer can edit this flow. Builders can compose scripts \
and flows that are already deployed."
))
}
fn check_composable_path(path: &str, id: &str) -> Result<(), Error> {
if path.starts_with("hub/") {
return Err(Error::PermissionDenied(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 +432,93 @@ 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 refs = 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!(refs.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());
}
}
#[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());
}
}
/// A step carrying a `hash` dispatches on that hash alone: the caller must verify the pair
/// exists and is readable, so the walk has to surface it rather than pass it through.
#[test]
fn composition_check_reports_version_pinned_steps() {
let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [
{"id": "a", "value": {"type": "script", "path": "f/x/s", "hash": "000000000000007b"}},
{"id": "b", "value": {"type": "script", "path": "f/x/t"}}
]})))
.unwrap();
assert_eq!(
refs.pinned_scripts,
vec![("f/x/s".to_string(), ScriptHash(123))]
);
}
#[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());
}
}
}
+64 -11
View File
@@ -1044,7 +1044,8 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre
}
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
/// accounts cannot log in and do not take a seat; a disabled member is not billed either. In a
/// workspace that granted operators builder rights, every operator counts as a developer.
///
/// The workspace is invoiced by a job outside this codebase that counts the same rows with its own
/// SQL. The two must be changed together: this rule disagreeing with that one is what bills a
@@ -1063,10 +1064,15 @@ pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats>
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?;
let (developers, operators) = if operator_can_build_flows(db, w_id).await? {
(row.developers + row.operators, 0)
} else {
(row.developers, row.operators)
};
Ok(BillableSeats {
developers: row.developers,
operators: row.operators,
seats: ((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64,
developers,
operators,
seats: ((developers as f64) + 0.5 * (operators as f64)).ceil() as i64,
})
}
@@ -1189,7 +1195,17 @@ pub fn invalidate_protection_rules_cache(workspace_id: &str) {
// Operator rights cache
lazy_static::lazy_static! {
static ref OPERATOR_RIGHTS_CACHE: Cache<String, (OperatorManageRights, i64)> = Cache::new(1000);
static ref OPERATOR_RIGHTS_CACHE: Cache<String, (OperatorRights, i64)> = Cache::new(1000);
}
/// Every operator right of a workspace, read and cached together because they share one
/// `operator_settings` column and one invalidation. The two groups have opposite polarity:
/// `builder_flows` is granted on request and costs a seat, the `manage` rights are held by
/// default and cost nothing.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct OperatorRights {
pub builder_flows: bool,
pub manage: OperatorManageRights,
}
/// Writes an operator may perform unless the workspace withdraws them. Unlike the visibility
@@ -1256,7 +1272,7 @@ impl OperatorManageRights {
///
/// Call it before opening an RLS transaction: it takes a connection from the root pool, and a
/// second pooled connection held alongside a transaction self-deadlocks on a one-connection pool.
async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result<OperatorManageRights> {
async fn operator_rights(db: &DB, workspace_id: &str) -> Result<OperatorRights> {
let now = chrono::Utc::now().timestamp();
if let Some((rights, expiry)) = OPERATOR_RIGHTS_CACHE.get(workspace_id) {
@@ -1265,10 +1281,12 @@ async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result<OperatorM
}
}
// Coalesced to true, matching `OperatorManageRights::default`: an absent key means the
// workspace never configured the right, not that it withdrew it.
// The builder key defaults to false, a right the workspace has to grant; the manage keys to
// true, rights it has to withdraw. An absent key means "never configured" for both, so the
// defaults have to differ here rather than at the call sites.
let row = sqlx::query!(
"SELECT COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",
"SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\",
COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",
COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"
FROM workspace_settings WHERE workspace_id = $1",
workspace_id
@@ -1282,7 +1300,10 @@ async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result<OperatorM
})?;
let rights = row
.map(|r| OperatorManageRights { schedules: r.schedules, triggers: r.triggers })
.map(|r| OperatorRights {
builder_flows: r.flows,
manage: OperatorManageRights { schedules: r.schedules, triggers: r.triggers },
})
.unwrap_or_default();
OPERATOR_RIGHTS_CACHE.insert(workspace_id.to_string(), (rights, now + 60));
@@ -1290,6 +1311,38 @@ async fn operator_manage_rights(db: &DB, workspace_id: &str) -> Result<OperatorM
Ok(rights)
}
/// Whether operators of this workspace may compose flows out of already-deployed runnables. Per
/// workspace, not per user: every operator gets it, and consumes a full seat for it.
pub async fn operator_can_build_flows(db: &DB, workspace_id: &str) -> Result<bool> {
Ok(operator_rights(db, workspace_id).await?.builder_flows)
}
/// Gate for a write only a workspace that granted builder rights lets operators perform. `action`
/// completes "Operators cannot {action} for security reasons".
pub async fn check_operator_can_build_flows(
db: &DB,
workspace_id: &str,
is_operator: bool,
action: &str,
) -> Result<()> {
if is_operator && !operator_can_build_flows(db, workspace_id).await? {
return Err(Error::PermissionDenied(format!(
"Operators cannot {action} for security reasons"
)));
}
Ok(())
}
/// Whether a membership consumes an operator (half) seat rather than an author seat. A builder
/// right makes an operator an author of deployable artifacts, so it weighs a full seat.
pub async fn consumes_operator_seat(
db: &DB,
workspace_id: &str,
is_operator: bool,
) -> Result<bool> {
Ok(is_operator && !operator_can_build_flows(db, workspace_id).await?)
}
/// Invalidate the operator rights cache for a workspace
pub fn invalidate_operator_rights_cache(workspace_id: &str) {
OPERATOR_RIGHTS_CACHE.remove(workspace_id);
@@ -1306,7 +1359,7 @@ pub async fn check_operator_can_manage(
is_operator: bool,
kind: ManageKind,
) -> Result<()> {
if is_operator && !operator_manage_rights(db, workspace_id).await?.has(kind) {
if is_operator && !operator_rights(db, workspace_id).await?.manage.has(kind) {
// 403, not 401: the caller is authenticated and simply lacks the right. The frontend reads
// an uncaught 401 as a dead session and logs the user out, so `NotAuthorized` here would
// eject an operator from the app instead of telling them why.
@@ -118,6 +118,21 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
(2, 2, 3)
);
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, operator_settings)
VALUES ('seat-root', '{\"builder_flows\": true}')",
)
.execute(&db)
.await
.unwrap();
windmill_common::workspaces::invalidate_operator_rights_cache("seat-root");
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(breakdown.developers, breakdown.operators, breakdown.seats),
(4, 0, 4),
"builder rights bill every operator as a developer"
);
insert_ws(&db, "seat-fork1", Some("seat-root"), false).await;
insert_ws(&db, "seat-fork2", Some("seat-root"), false).await;
// A deleted fork itself is not counted...
+1 -1
View File
@@ -413,7 +413,7 @@ export async function pushWorkspaceSettings(
const localOperatorSettings = localSettings.operator_settings && {
...localSettings.operator_settings,
};
for (const key of ["manage_schedules", "manage_triggers"] as const) {
for (const key of ["builder_flows", "manage_schedules", "manage_triggers"] as const) {
const remote = settings.operator_settings?.[key];
if (localOperatorSettings && localOperatorSettings[key] === undefined && remote !== undefined) {
localOperatorSettings[key] = remote;
+76
View File
@@ -0,0 +1,76 @@
# Operator builder rights
`operator_settings.builder_flows` lets every operator of a workspace compose flows 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 this does not move it.
It is a write right, unlike the visibility flags beside it, and unlike the withdrawable rights in
`docs/operator-write-rights.md` it is granted on request and costs a seat. Read it with
`windmill_common::workspaces::operator_can_build_flows` (60s cache, shared with the withdrawable
rights) and gate a write with `check_operator_can_build_flows`.
## 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 what a value-only walk cannot authorize, for the caller to check against its own
permissions:
- **the worker tags the steps pin**, or a builder routes a job onto a privileged worker group;
- **every runnable a step references**. `script_to_payload` resolves a step's path with the root DB
handle (`db_authed = None`) and returns the referenced runnable's `on_behalf_of`, which
`worker_flow` then applies to the step job. So composing a path is enough to run it, and to run
it as whoever it runs as: `validate_operator_composed_flow` re-checks each path under the
caller's RLS. This is the general case; the one below is on top of it, not instead of it.
- **the `(path, hash)` of every version-pinned step**. A step carrying a `hash` is dispatched by
that hash alone, with the path beside it never consulted, so a readable path paired with another
script's hash still runs that other script.
The value is not the only code a flow carries: the schema's `x-windmill-dyn-select-code` fills its
dynamic dropdowns, run as whoever loads the form, so `validate_operator_flow` refuses it like a
step's code. A builder therefore cannot save a developer's flow that has dropdowns; its editor
still previews them through the deployed flow rather than the inline route operators are refused.
Which developer flows a builder may edit at all is left to permissions: write access to the flow
or its folder.
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. A flow draft goes through `validate_operator_flow`
too: a developer who loads a builder's draft in the editor runs its code as themselves.
## Billing
An operator of a builder workspace consumes a full author seat: composing deployable artifacts
makes them an author, and there is no half-author. `consumes_operator_seat` is the seat-role
helper; the EE counting queries share `OPERATOR_SEAT_SQL` so the displayed, enforced and reported
numbers agree. The one exception is `get_user_usage` in `stats_ee.rs`: it is a compile-checked
`query!`, which cannot interpolate the constant, so it spells the predicate out. Change both
together.
On cloud, `billable_seats` applies the same rule; see its doc for the out-of-repo invoice it must
match.
Granting the right 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, so re-saving settings that already have the right on is a
zero delta and never blocks.
## Accepted risks
- 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.
- A builder writes JavaScript expressions: step inputs, branch predicates, `stop_after_if` and
`suspend`. They run in QuickJS with no filesystem or network access, and forbidding them would
leave nothing to compose with.
+1 -1
View File
@@ -79,7 +79,7 @@ authorizing writes on every other replica until its own entry expires.
These name capabilities operators already hold, so absence has to mean "never configured", not a
value. That is easy to get wrong in two places, and the obvious implementation gets both wrong:
- The read coalesces to **true** (`operator_manage_rights`), including for a workspace with no
- The read coalesces to **true** (`operator_rights`), including for a workspace with no
`workspace_settings` row, which is what `OperatorManageRights::default` is for. Coalescing to
false instead revokes the right on upgrade for every workspace that ever saved operator settings,
since those rows carry explicit keys and none of them is this one.
@@ -19,6 +19,7 @@
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import Label from './Label.svelte'
import { sendUserToast } from '$lib/toast'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import Toggle from './Toggle.svelte'
import {
DynamicInput,
@@ -35,9 +36,14 @@
import Section from '$lib/components/Section.svelte'
import Editor from './Editor.svelte'
import AddPropertyV2 from './schema/AddPropertyV2.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const operatorBuilderFlows = useOperatorBuilderFlows()
// export let openEditTab: () => void = () => {}
const dispatch = createEventDispatcher()
@@ -88,6 +94,8 @@
schemaFormClassName?: string
onChange?: (args: Record<string, any>) => void
workspace?: string | undefined
/** The deployed flow an operator's dropdown previews read their options from. */
deployedFlowPath?: string
}
let {
@@ -127,7 +135,8 @@
extraTab,
schemaFormClassName = undefined,
onChange = undefined,
workspace = undefined
workspace = undefined,
deployedFlowPath = undefined
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
@@ -469,11 +478,12 @@
order: e.detail
}
}}
helperScript={{
source: 'inline',
code: dynCode!,
lang: dynLang!
}}
helperScript={DynamicInput.flowHelperScript(
dynCode,
dynLang,
deployedFlowPath,
operatingUser.current?.operator
)}
prettifyHeader={isAppInput}
disabled={!!previewSchema}
{diff}
@@ -486,7 +496,7 @@
{@render runButton?.()}
{#if dynamicFunctions.length > 0}
{#if dynamicFunctions.length > 0 && !$operatorBuilderFlows}
<Section
label="Dynamic input functions"
collapsable={true}
@@ -815,7 +825,7 @@
{#each typeOptions as x}
<ToggleButton value={x[1]} label={x[0]} {item} />
{/each}
{#if showDynOpt}
{#if showDynOpt && !$operatorBuilderFlows}
{#each DYNAMIC_OPTIONS as x}
<ToggleButton value={x[1]} label={x[0]} {item} />
{/each}
@@ -106,12 +106,13 @@
import { UserDraft } from '$lib/userDraft.svelte'
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext'
import { useTriggerLock } from '$lib/operatorWriteRights'
import { useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const triggerLock = useTriggerLock()
const operatorBuilderFlows = useOperatorBuilderFlows()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
@@ -1519,7 +1520,7 @@
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
{#key renderCount}
{#if !actingUser?.operator}
{#if !actingUser?.operator || $operatorBuilderFlows}
{#if $pathStore}
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
{/if}
@@ -1673,7 +1674,9 @@
{forceTestTab}
{highlightArg}
aiChatOpen={aiChatManager.open}
showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false}
showFlowAiButton={!disableAi &&
customUi?.topBar?.aiBuilder != false &&
!$operatorBuilderFlows}
toggleAiChat={() => aiChatManager.toggleOpen()}
{sessionOpen}
onOpenPreview={flowPreviewButtons?.openPreview}
@@ -1702,7 +1705,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}
@@ -28,7 +28,7 @@
RefreshCw,
X
} from 'lucide-svelte'
import { sendUserToast, type StateStore } from '$lib/utils'
import { DynamicInput, sendUserToast, type StateStore } from '$lib/utils'
import { dfs } from './flows/dfs'
import { sliceModules } from './flows/flowStateUtils.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
@@ -44,9 +44,13 @@
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
interface Props {
previewMode: 'upTo' | 'whole'
@@ -552,14 +556,14 @@
savedArgs = $state.snapshot(previewArgs.val)
}}
bind:isValid
helperScript={flowStore.val.schema?.['x-windmill-dyn-select-code'] &&
flowStore.val.schema?.['x-windmill-dyn-select-lang']
? {
source: 'inline',
code: flowStore.val.schema['x-windmill-dyn-select-code'] as string,
lang: flowStore.val.schema['x-windmill-dyn-select-lang'] as ScriptLang
}
: undefined}
helperScript={DynamicInput.flowHelperScript(
flowStore.val.schema?.['x-windmill-dyn-select-code'] as string | undefined,
flowStore.val.schema?.['x-windmill-dyn-select-lang'] as
| ScriptLang
| undefined,
$initialPathStore,
operatingUser.current?.operator
)}
/>
</div>
{/key}
@@ -9,6 +9,7 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
@@ -42,6 +43,8 @@
import EditInForkButton from './EditInForkButton.svelte'
import { isCloudHosted } from '$lib/cloud'
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
flow: Flow & {
draft_only?: boolean
@@ -217,6 +220,7 @@
let { draft_only, path, archived } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
const hideForOperator = $userStore?.operator && !$operatorBuilderFlows
if (draft_only) {
return [
...selectMenuItems(rowSelection),
@@ -251,7 +255,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: hideForOperator
}
]
}
@@ -267,7 +271,7 @@
icon: GitFork,
href: `${base}/flows/add?template=${path}`,
disabled: !showEditButton,
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
@@ -293,7 +297,7 @@
moveDrawer.openDrawer(path, flow.summary, 'flow')
},
disabled: !owner || archived || !canEdit,
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: 'Copy path',
@@ -321,7 +325,7 @@
action: () => {
flowHistory?.open()
},
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: 'Schedule',
@@ -330,7 +334,7 @@
scheduleEditor?.openNew(true, path)
},
disabled: archived,
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: 'Permissions',
@@ -338,7 +342,7 @@
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
},
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: archived ? 'Unarchive' : 'Archive',
@@ -348,7 +352,7 @@
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator
hide: hideForOperator
},
{
displayName: 'Delete',
@@ -365,7 +369,7 @@
},
type: 'delete',
disabled: !owner || !canEdit,
hide: $userStore?.operator
hide: hideForOperator
}
]
}}
@@ -168,6 +168,7 @@ import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRout
import { appendAttachedFilesRoster } from './files/fileTools'
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
import { navigationOperatorBuilderFlows } from '$lib/operatorWriteRights'
// Compaction of the stored history: once the projected request size
// (contextTokens — the provider's report when current, a fresh chars/4
@@ -300,6 +301,8 @@ export function supportsPlanMode(mode: AIMode): boolean {
return PLAN_MODES.has(mode)
}
const isOperatorBuilderFlows = fromStore(navigationOperatorBuilderFlows)
export function isAIModeVisible(mode: AIMode): boolean {
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
}
@@ -1435,12 +1438,19 @@ export class AIChatManager implements ChatViewHost {
.map((s) => ({ ...s, kind: 'skill' as const }))
])
// The flow and script builders both write code, which the backend refuses from an operator
// with the builder right: 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,
!this.disabledModes.script &&
!isOperatorBuilderFlows.current,
flow:
this.flowAiChatHelpers !== undefined &&
!this.disabledModes.flow &&
!isOperatorBuilderFlows.current,
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app,
navigator: !this.disabledModes.navigator,
ask: !this.disabledModes.ask,
@@ -20,6 +20,7 @@
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { hubBaseUrlStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import { getLatestHashForScript } from '$lib/scripts'
import { sendUserToast, type Item } from '$lib/utils'
@@ -30,6 +31,7 @@
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
flowModuleValue?: FlowModuleValue | undefined
@@ -102,7 +104,7 @@
const scriptItems: Item[] = $derived.by(() => {
if (flowModuleValue?.type !== 'script') return []
const items: Item[] = []
if (!isHub && customUi?.scriptEdit != false) {
if (!isHub && customUi?.scriptEdit != false && !$operatorBuilderFlows) {
items.push({
displayName: "Edit the script's code",
icon: Pen,
@@ -146,7 +148,7 @@
})
}
}
if (customUi?.scriptFork != false) {
if (customUi?.scriptFork != false && !$operatorBuilderFlows) {
items.push({
displayName: 'Fork into an inline script',
icon: GitFork,
@@ -820,6 +820,7 @@
workspace={opWs}
editTab={chatInputsEditTab ? 'inputEditor' : undefined}
showDynOpt
deployedFlowPath={$initialPathStore}
bind:dynCode
bind:dynLang
on:delete={(e) => {
@@ -878,6 +879,7 @@
addPropertyV2?.handleDeleteArgument([e.detail])
}}
showDynOpt
deployedFlowPath={$initialPathStore}
displayWebhookWarning
editTab={$flowInputEditorState?.selectedTab}
{previewSchema}
@@ -8,6 +8,7 @@
import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte'
import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts'
import { defaultScripts, enterpriseLicense, hubBaseUrlStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import type { SupportedLanguage } from '$lib/common'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
@@ -34,6 +35,7 @@
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
@@ -155,7 +157,10 @@
preFilter: 'all' | 'workspace' | 'hub',
selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
) {
if (['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)) {
if (
!$operatorBuilderFlows &&
['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)
) {
if (!selected && preFilter == 'all') {
inlineScripts = langs.filter((lang) => {
return (
@@ -248,6 +253,7 @@
let showAiRows = $derived(
!disableAi &&
!$copilotInfo.workspaceDisabled &&
!$operatorBuilderFlows &&
funcDesc?.length > 0 &&
kind != 'failure' &&
kind != 'preprocessor' &&
@@ -276,9 +282,14 @@
preFilter === 'all' &&
!selected &&
customUi?.aiSandbox != false &&
!$operatorBuilderFlows &&
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(!$operatorBuilderFlows)
// 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)
@@ -331,7 +342,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}
@@ -536,7 +547,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>
@@ -39,6 +39,7 @@
import type { ButtonProp } from '$lib/components/diffEditorTypes'
import { loadSchemaFromModule } from '../flowInfers'
import { type Job } from '$lib/gen'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { checkIfParentLoop } from '../utils.svelte'
import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte'
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
@@ -74,6 +75,7 @@
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const {
selectionManager,
@@ -235,8 +237,11 @@
!flowModule.value.path?.startsWith('hub/') &&
flowModule.value.hash == undefined &&
customUi?.scriptEdit != false &&
!$operatorBuilderFlows &&
$workspaceScriptSettingsDrawer != undefined
)
// Same rule as the graph node's Run button (FlowModuleSchemaItem).
let builderCannotTestStep = $derived($operatorBuilderFlows && flowModule.value.type === 'script')
let workspaceScriptNoEditReason = $derived(
flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings
? undefined
@@ -288,7 +293,7 @@
}
function onKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') {
if ((event.ctrlKey || event.metaKey) && event.key == 'Enter' && !builderCannotTestStep) {
event.preventDefault()
selected = 'test'
modulePreview?.runTestWithStepArgs()
@@ -1121,7 +1126,9 @@
{#if !preprocessorModule}
<Tab value="inputs" label={isAgentTool ? 'Tool input' : 'Step Input'} />
{/if}
<Tab value="test" label={isAgentTool ? 'Test this tool' : 'Test this step'} />
{#if !builderCannotTestStep}
<Tab value="test" label={isAgentTool ? 'Test this tool' : 'Test this step'} />
{/if}
{#if canShowChatTab && flowModule.value.type === 'aiagent'}
<Tab
value="chat"
@@ -1319,7 +1326,7 @@
{/if}
</PropPickerWrapper>
</div>
{:else if visibleSelected === 'test'}
{:else if visibleSelected === 'test' && !builderCannotTestStep}
{#if debugMode && isDebuggableScript}
<div transition:slide={{ duration: 200 }}>
<DebugToolbar
@@ -7,7 +7,8 @@
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { emptyString, type DynamicInput } from '$lib/utils'
import { emptyString, DynamicInput } from '$lib/utils'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import { saveFlowChatInputs } from './flowChatProps'
@@ -73,15 +74,16 @@
subject = 'flow'
}: Props = $props()
const operatingUser = useOperatingUser()
// Derive helperScript for dynamic inputs from schema
const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => {
const dynCode = additionalInputsSchema?.['x-windmill-dyn-select-code']
const dynLang = additionalInputsSchema?.['x-windmill-dyn-select-lang']
if (dynCode && dynLang) {
return { source: 'inline', code: dynCode, lang: dynLang }
}
return undefined
})
const dynamicInputHelperScript = $derived(
DynamicInput.flowHelperScript(
additionalInputsSchema?.['x-windmill-dyn-select-code'],
additionalInputsSchema?.['x-windmill-dyn-select-lang'],
path,
operatingUser.current?.operator
)
)
// The composer's attachments feed this input, and the paperclip is its whole editor.
const attachmentsTarget = $derived.by(() => {
@@ -35,6 +35,9 @@
import DiffActionBar from './DiffActionBar.svelte'
import { getGraphContext } from '$lib/components/graph/graphContext'
import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
selected?: boolean
@@ -128,6 +131,12 @@
let newId: string = $state(untrack(() => id) ?? '')
let mod = $derived(
id && flowStore?.val?.value ? dfsPreviousResults(id, flowStore.val, false)[0] : undefined
)
// A script step is tested as a script preview, which operators are refused: running the
// deployed runnable is left to "Test flow".
let builderCannotTestStep = $derived($operatorBuilderFlows && mod?.value.type === 'script')
let moduleTest: ModuleTest | undefined = $state(undefined)
let testIsLoading = $state(false)
let hover = $state(false)
@@ -234,8 +243,6 @@
{/if}
{#if deletable && id && flowStore && outputPickerVisible}
{@const flowStoreVal = flowStore.val}
{@const mod = flowStoreVal?.value ? dfsPreviousResults(id, flowStoreVal, false)[0] : undefined}
{#if mod && flowStateStore?.val?.[id]}
<ModuleTest
bind:this={moduleTest}
@@ -454,7 +461,7 @@
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible}
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible && !builderCannotTestStep}
<div class="bg-surface rounded-md" transition:fade={{ duration: 100 }} data-run-button={id}>
{#if !testIsLoading}
<Button
@@ -12,12 +12,14 @@
import RefreshButton from '$lib/components/common/button/RefreshButton.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { ResourceService } from '$lib/gen'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import type { FlowEditorContext } from '../types'
import { logReusableAgentUsage } from '../agentTelemetry'
import { BotIcon, Loader2, Plus } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatorBuilderFlows = useOperatorBuilderFlows()
const dispatch = createEventDispatcher()
interface Props {
@@ -52,7 +54,11 @@
| 'failure'
| 'aisandbox'
| 'aiagent' = $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(() => $operatorBuilderFlows) ? 'workspace' : 'all'
)
let loading = $state(false)
let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure'))
@@ -73,7 +79,8 @@
let savedAgentsLoading = $state(false)
let savedAgentsWs: string | undefined = undefined
async function loadSavedAgents() {
if (!ws || savedAgentsWs === ws) {
// The backend refuses a linked agent in a builder's flow.
if (!ws || savedAgentsWs === ws || $operatorBuilderFlows) {
return
}
savedAgentsLoading = true
@@ -185,13 +192,13 @@
<StepGenQuick
bind:this={stepGen}
on:escape={() => dispatch('close')}
{disableAi}
disableAi={disableAi || $operatorBuilderFlows}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
{#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !$operatorBuilderFlows}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
<RefreshButton
@@ -317,7 +324,7 @@
}}
/>
{/if}
{#if customUi?.aiSandbox != false}
{#if customUi?.aiSandbox != false && !$operatorBuilderFlows}
<TopLevelNode
label="AI Sandbox"
selected={selectedKind === 'aisandbox'}
@@ -353,7 +360,9 @@
<kbd class="!text-xs">&crarr;</kbd>
{/if}
</Button>
{#if savedAgentsLoading}
{#if $operatorBuilderFlows}
<!-- Linked agents are refused to builders, see `loadSavedAgents`. -->
{:else if savedAgentsLoading}
<div class="flex items-center gap-2 p-2 text-xs text-tertiary">
<Loader2 size={13} class="animate-spin" /> Loading saved agents
</div>
@@ -25,11 +25,14 @@
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { importStore } from '$lib/components/apps/store'
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import YAML from 'yaml'
import type { Snippet } from 'svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
/** Replaces the default `New` button, e.g. with an inline text link. */
trigger?: Snippet
@@ -247,6 +250,14 @@
}
}
// A builder composes runnables that already exist, so only flows are offered: everything else
// here writes code, which the backend refuses from an operator.
// Derived, not computed once: switching workspace only sets `workspaceStore`, it does not
// remount this component, so a snapshot would keep the previous workspace's kinds.
const options: Option[] = $derived(
$operatorBuilderFlows ? allOptions.filter((o) => o.key === 'flow') : allOptions
)
// the doc panel only shows while an option is hovered or focused, so the menu opens compact
let activeKey: string | undefined = $state(undefined)
// every option's import action, surfaced together under the bottom "Import" submenu.
@@ -256,7 +267,7 @@
...(onImportHubProject
? [{ label: 'Import a hub project', onSelect: onImportHubProject }]
: []),
...allOptions.flatMap((o) => o.extras ?? [])
...options.flatMap((o) => o.extras ?? [])
])
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
@@ -372,7 +383,7 @@
activeKey = undefined
}
})
let active = $derived(allOptions.find((o) => o.key === activeKey))
let active = $derived(options.find((o) => o.key === activeKey))
let activeAc = $derived(active ? accentClasses[active.accent] : undefined)
// shared YAML/JSON import drawer, reused by every "Import …" extra
@@ -524,7 +535,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'}
@@ -17,6 +17,7 @@
import { resource } from 'runed'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
import { disableHubStore, userStore, workspaceStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
@@ -60,6 +61,9 @@
import { base } from '$lib/base'
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
subtab?: 'flow' | 'script' | 'app'
showEditButtons?: boolean
@@ -338,7 +342,9 @@
canWrite:
canWrite(it.path, (it.extra_perms ?? {}) as any, $userStore) &&
(it.type === 'script' || it.workspace_id == $workspaceStore) &&
!$userStore?.operator
// The builder right covers flows only; a script or an app is still off limits, so
// the row must not offer edit or delete for those.
(!$userStore?.operator || (it.type === 'flow' && $operatorBuilderFlows))
}
// combinedItems reads a script's time from `created_at`; the endpoint's
// unified `edited_at` holds exactly that for scripts.
@@ -1064,7 +1070,7 @@
* whose direct-deploy protection cleared `showEditButtons` — must not be shown them.
* Reading archived items is not a write, so it is not gated on this.
*/
let canCreateHere = $derived(!$userStore?.operator && showEditButtons)
let canCreateHere = $derived((!$userStore?.operator || $operatorBuilderFlows) && showEditButtons)
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
// false until the first load resolves: a skeleton already means "loading", and the
@@ -1876,9 +1882,12 @@
the menu itself does no permission check. -->
{#if canCreateHere}
<!-- No hub entry where the instance has the hub turned off: the same setting the
script and flow hub pickers observe. -->
script and flow hub pickers observe. Nor for a builder: a hub project brings
scripts and apps along. -->
<CreateActionsMenu
onImportHubProject={$disableHubStore ? undefined : () => (hubPickerOpen = true)}
onImportHubProject={$disableHubStore || $operatorBuilderFlows
? undefined
: () => (hubPickerOpen = true)}
/>
{/if}
</div>
@@ -4,9 +4,12 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import { disableHubStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import CreateActionsMenu from './CreateActionsMenu.svelte'
import HubTemplatePicker from './HubTemplatePicker.svelte'
const operatorBuilderFlows = useOperatorBuilderFlows()
interface Props {
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
onPick: (project: HubProjectPick) => void
@@ -35,6 +38,9 @@
// `border-light`; only the container outline steps up, so nothing outweighs its frame.
const rowOpacities = [1, 0.7, 0.4]
// A builder gets no hub template: a hub project brings scripts and apps along.
const showHub = $derived(!$disableHubStore && !$operatorBuilderFlows)
// The inline "create a new one" link is the anchor for the very same New menu the
// toolbar button opens, so the menu pops next to the words that promised it.
let newLinkEl: HTMLButtonElement | undefined = $state(undefined)
@@ -93,9 +99,9 @@
Your scripts, flows and apps will show up here.
{/if}
{#if canCreate}
<!-- The hub half goes when the instance has the hub turned off, and the remaining link
<!-- The hub half goes when the hub is off or for a builder, and the remaining link
opens the sentence instead of continuing it. -->
{#if !$disableHubStore}
{#if showHub}
<!-- Opens downward into the page rather than upward into the hero: the caption sits
high when the AI composer is hidden, so the room is below it. `fitViewport` caps
the box on a short viewport, which is why the height below is definite and the
@@ -137,7 +143,7 @@
<button
bind:this={newLinkEl}
class="border-b border-transparent text-accent hover:border-accent"
>{$disableHubStore ? 'Create a new one' : 'create a new one'}</button
>{showHub ? 'create a new one' : 'Create a new one'}</button
>.
{/snippet}
</CreateActionsMenu>
@@ -6,6 +6,7 @@
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'
@@ -13,7 +14,7 @@
import { SaveIcon, EyeIcon, EyeOffIcon } from 'lucide-svelte'
import { untrack } from 'svelte'
let operatorWorkspaceSettings = $state({
const defaultVisibility = {
runs: true,
schedules: true,
resources: true,
@@ -24,31 +25,47 @@
groups: true,
folders: true,
workers: true
})
}
let operatorWorkspaceSettings = $state({ ...defaultVisibility })
// Kept out of `operatorWorkspaceSettings` so the visibility table's "Enable all" never flips a
// write right, and so these rows stay out of that table. Withdrawable rather than granted:
// operators hold them until an admin turns them off.
// write right, and so these rows stay out of that table.
let builderFlows = $state(false)
// Withdrawable rather than granted: operators hold these until an admin turns them off.
let manageSchedules = $state(true)
let manageTriggers = $state(true)
let originalSettings = $state({
...untrack(() => operatorWorkspaceSettings),
builder_flows: false,
manage_schedules: true,
manage_triggers: true
})
let isChanged = $state(false)
let currentWorkspace: string | null = $state(null)
let confirmBuilderOpen = $state(false)
// Saving sends every key, so saving before the load, or over a late response for another
// workspace, would write defaults over the rights stored there.
let loadedWorkspace: string | null = $state(null)
const settingsPayload = $derived({
...operatorWorkspaceSettings,
builder_flows: builderFlows,
manage_schedules: manageSchedules,
manage_triggers: manageTriggers
})
// The seat cost lands when the right is first granted, so confirm only on that transition.
const grantsBuilderRight = $derived(builderFlows && !originalSettings.builder_flows)
function onSaveClicked() {
if (grantsBuilderRight) {
confirmBuilderOpen = true
} else {
saveSettings()
}
}
async function saveSettings() {
try {
await WorkspaceService.updateOperatorSettings({
@@ -60,7 +77,7 @@
sendUserToast('Operator settings saved successfully!', false)
} catch (error) {
console.error('Error updating operator settings:', error)
sendUserToast('Failed to save operator settings.', true)
sendUserToast(`Failed to save operator settings: ${error?.body ?? error}`, true)
}
}
@@ -84,20 +101,23 @@
currentWorkspace = ws
const settings = await WorkspaceService.getSettings({ workspace: ws })
if (ws !== currentWorkspace) return
if (settings.operator_settings !== null) {
const {
manage_schedules: remoteSchedules,
manage_triggers: remoteTriggers,
...remoteVisibility
} = settings.operator_settings ?? {}
operatorWorkspaceSettings = { ...operatorWorkspaceSettings, ...remoteVisibility }
manageSchedules = remoteSchedules ?? true
manageTriggers = remoteTriggers ?? true
originalSettings = {
...operatorWorkspaceSettings,
manage_schedules: manageSchedules,
manage_triggers: manageTriggers
}
// Every value is reset, null settings included: one kept from the previous workspace
// would be saved here with the next unrelated change, builder rights among them.
const {
builder_flows: remoteFlows,
manage_schedules: remoteSchedules,
manage_triggers: remoteTriggers,
...remoteVisibility
} = settings.operator_settings ?? {}
operatorWorkspaceSettings = { ...defaultVisibility, ...remoteVisibility }
builderFlows = remoteFlows ?? false
manageSchedules = remoteSchedules ?? true
manageTriggers = remoteTriggers ?? true
originalSettings = {
...operatorWorkspaceSettings,
builder_flows: builderFlows,
manage_schedules: manageSchedules,
manage_triggers: manageTriggers
}
loadedWorkspace = ws
})()
@@ -124,7 +144,7 @@
>
{#snippet action()}
<Button
on:click={saveSettings}
on:click={onSaveClicked}
startIcon={{ icon: SaveIcon }}
disabled={!isChanged || loadedWorkspace !== $workspaceStore}
variant="accent"
@@ -133,6 +153,20 @@
</Button>
{/snippet}
<Section
small
label="Build flows"
description="Let operators compose flows out of scripts and flows that are already deployed. They still cannot write code. Granting this makes each operator consume a full seat instead of half a seat."
wrapperClass="mb-6"
class="flex flex-col gap-y-1"
>
<Toggle
bind:checked={builderFlows}
options={{ right: 'Operators can build flows' }}
size="xs"
/>
</Section>
<Section
small
label="Change schedules and triggers"
@@ -216,3 +250,27 @@
</DataTable>
</Section>
</Section>
<ConfirmationModal
open={confirmBuilderOpen}
title="Give operators builder rights"
type="info"
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 wherever their folder permissions already let them
write. Review those permissions before enabling.
</span>
</div>
</ConfirmationModal>
+24 -2
View File
@@ -1,5 +1,5 @@
import { derived } from 'svelte/store'
import { userWorkspaces } from '$lib/stores'
import { derived, type Readable } from 'svelte/store'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
/**
@@ -24,3 +24,25 @@ function writeLock(key: 'manage_schedules' | 'manage_triggers', noun: string) {
export const useScheduleLock = () => writeLock('manage_schedules', 'schedules')
/** Reads context: call during component initialisation. */
export const useTriggerLock = () => writeLock('manage_triggers', 'triggers')
/**
* True when the user is an operator of `workspace` and it granted operators the right to compose
* flows out of runnables that are already deployed. They still author no code, and everywhere else
* `operator` keeps meaning read-only, so a gate on the operator role has to consult this before
* refusing. `operator_settings` is null for a non-operator, so this is false for them.
*/
function builderFlows(workspace: Readable<string | undefined>): Readable<boolean> {
return derived(
[userWorkspaces, workspace],
([$userWorkspaces, $workspace]) =>
$userWorkspaces.find((w) => w.id === $workspace)?.operator_settings?.builder_flows === true
)
}
/** Builder rights in the operating workspace. Reads context: call during component
* initialisation. */
export const useOperatorBuilderFlows = () => builderFlows(useOperatingWorkspace())
/** Builder rights in the navigation workspace, for code outside any component: the legacy AI
* chat is one instance for the whole app and acts on the workspace the nav is on. */
export const navigationOperatorBuilderFlows = builderFlows(workspaceStore)
+14
View File
@@ -637,6 +637,20 @@ export namespace DynamicInput {
| { source: 'deployed'; path: string; runnable_kind: RunnableKind }
| { source: 'inline'; code: string; lang: ScriptLang }
/** A flow's dropdown options for its editor. An operator may not run request-supplied code, and
* a builder cannot change the stored code, so an operator reads it from the deployed flow. */
export function flowHelperScript(
code: string | undefined,
lang: ScriptLang | undefined,
deployedPath: string | undefined,
operator: boolean | undefined
): HelperScript | undefined {
if (!code || !lang) return undefined
return operator && deployedPath
? { source: 'deployed', path: deployedPath, runnable_kind: 'flow' }
: { source: 'inline', code, lang }
}
export const generatePythonFnTemplate = (functionName: string): string => {
return `
def ${functionName}():
@@ -29,6 +29,7 @@
import RunForm from '$lib/components/RunForm.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
import { sendUserToast } from '$lib/toast'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
@@ -86,6 +87,8 @@
} from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
const operatorBuilderFlows = useOperatorBuilderFlows()
let flow: Flow | undefined = $state()
let can_write = $state(false)
let shareModal: ShareModal | undefined = $state()
@@ -292,6 +295,10 @@
}
}
// Operators with the builder right author flows out of deployed runnables; every other
// operator is read-only here.
let canAuthorFlow = $derived(!$userStore?.operator || $operatorBuilderFlows)
let moveDrawer: MoveDrawer | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let runForm: RunForm | undefined = $state()
@@ -299,7 +306,7 @@
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
const buttons: any = []
if (flow && !$userStore?.operator) {
if (flow && canAuthorFlow) {
buttons.push({
label: 'Fork',
description: `Start a new flow from a copy of this one`,
@@ -360,10 +367,11 @@
}
})
if (!flow || $userStore?.operator || !can_write) {
if (!flow || !canAuthorFlow || !can_write) {
return buttons
}
// The builder right covers flows only; building an app is still refused to operators.
if (!$userStore?.operator) {
buttons.push({
label: 'Build app',
@@ -381,18 +389,18 @@
startIcon: LayoutDashboard
}
})
buttons.push({
label: 'Edit',
buttonProps: {
href: `${base}/flows/edit/${path}`,
variant: 'accent',
unifiedSize: 'md',
disabled: !can_write || !showEditButtons,
startIcon: Pen
}
})
}
buttons.push({
label: 'Edit',
buttonProps: {
href: `${base}/flows/edit/${path}`,
variant: 'accent',
unifiedSize: 'md',
disabled: !can_write || !showEditButtons,
startIcon: Pen
}
})
return buttons
}
@@ -412,7 +420,7 @@
flow: Flow | undefined,
deployUiSettings: WorkspaceDeployUISettings | undefined
) {
if (!flow || $userStore?.operator) return []
if (!flow || !canAuthorFlow) return []
const menuItems: any = []
@@ -431,15 +439,21 @@
})
}
menuItems.push({
label: 'Audit logs',
Icon: Eye,
onclick: () => {
goto(`/audit_logs?resource=${flow?.path}`)
}
})
// The builder right opens this menu to operators; audit logs stay behind their own setting.
if (
!$userStore?.operator ||
$userWorkspaces.find((w) => w.id === $workspaceStore)?.operator_settings?.audit_logs
) {
menuItems.push({
label: 'Audit logs',
Icon: Eye,
onclick: () => {
goto(`/audit_logs?resource=${flow?.path}`)
}
})
}
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'),