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