diff --git a/backend/.sqlx/query-269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed.json b/backend/.sqlx/query-269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed.json new file mode 100644 index 0000000000..07d46964f9 --- /dev/null +++ b/backend/.sqlx/query-269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE name = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "owners", + "type_info": "VarcharArray" + }, + { + "ordinal": 4, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "default_permissioned_as", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "269197b692a1e451a31f14701c8ca324c8e40d2923dfa07b8e4b3934afc16fed" +} diff --git a/backend/.sqlx/query-6360911d4f30161fcb02b32e2c27b895427b7da2a4a005915b527837eb07b0f2.json b/backend/.sqlx/query-6360911d4f30161fcb02b32e2c27b895427b7da2a4a005915b527837eb07b0f2.json new file mode 100644 index 0000000000..49ce944f42 --- /dev/null +++ b/backend/.sqlx/query-6360911d4f30161fcb02b32e2c27b895427b7da2a4a005915b527837eb07b0f2.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "6360911d4f30161fcb02b32e2c27b895427b7da2a4a005915b527837eb07b0f2" +} diff --git a/backend/.sqlx/query-7a0d4851d0eb9aa9a9eb60e8163c57a6a6f20ec6f4663ce602eca5e798db00f4.json b/backend/.sqlx/query-7a0d4851d0eb9aa9a9eb60e8163c57a6a6f20ec6f4663ce602eca5e798db00f4.json new file mode 100644 index 0000000000..9b95d98bdd --- /dev/null +++ b/backend/.sqlx/query-7a0d4851d0eb9aa9a9eb60e8163c57a6a6f20ec6f4663ce602eca5e798db00f4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7a0d4851d0eb9aa9a9eb60e8163c57a6a6f20ec6f4663ce602eca5e798db00f4" +} diff --git a/backend/.sqlx/query-86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7.json b/backend/.sqlx/query-86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7.json new file mode 100644 index 0000000000..82f4b5f8e6 --- /dev/null +++ b/backend/.sqlx/query-86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as FROM folder WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "86c196327af8705a33ab9a707b4a87a441f800c53885b30f9a9578217e87f2b7" +} diff --git a/backend/.sqlx/query-977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c.json b/backend/.sqlx/query-977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c.json new file mode 100644 index 0000000000..f6512e6817 --- /dev/null +++ b/backend/.sqlx/query-977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "VarcharArray", + "Jsonb", + "Text", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "977e696c820d1412b56086925a700f59898edc9851e2364485a70071ece7bc1c" +} diff --git a/backend/.sqlx/query-ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb.json b/backend/.sqlx/query-ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb.json new file mode 100644 index 0000000000..ff06d78624 --- /dev/null +++ b/backend/.sqlx/query-ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as)\n SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as\n FROM folder\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ae949d9470fe6687b312fbd9d617d47813a035eb948f0312a8168bb1365607fb" +} diff --git a/backend/.sqlx/query-b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400.json b/backend/.sqlx/query-b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400.json new file mode 100644 index 0000000000..593d49d450 --- /dev/null +++ b/backend/.sqlx/query-b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "owners", + "type_info": "VarcharArray" + }, + { + "ordinal": 4, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "default_permissioned_as", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "b87d104a97026929983f27a7607490d08b65637a02fb7821571978812144c400" +} diff --git a/backend/.sqlx/query-e5c764dd6b06065a9ba07a4124ea7e3fb0c0325c3a4c91c83d9daddc525da76d.json b/backend/.sqlx/query-e5c764dd6b06065a9ba07a4124ea7e3fb0c0325c3a4c91c83d9daddc525da76d.json new file mode 100644 index 0000000000..69faf7153e --- /dev/null +++ b/backend/.sqlx/query-e5c764dd6b06065a9ba07a4124ea7e3fb0c0325c3a4c91c83d9daddc525da76d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT default_permissioned_as FROM folder WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "default_permissioned_as", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e5c764dd6b06065a9ba07a4124ea7e3fb0c0325c3a4c91c83d9daddc525da76d" +} diff --git a/backend/.sqlx/query-fa30986b390fb5c89a91fcfc67c00a336daccef6651774cb4b74cacbfe6e7342.json b/backend/.sqlx/query-fa30986b390fb5c89a91fcfc67c00a336daccef6651774cb4b74cacbfe6e7342.json new file mode 100644 index 0000000000..63584dab5f --- /dev/null +++ b/backend/.sqlx/query-fa30986b390fb5c89a91fcfc67c00a336daccef6651774cb4b74cacbfe6e7342.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fa30986b390fb5c89a91fcfc67c00a336daccef6651774cb4b74cacbfe6e7342" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0a9b0cdc2e..8a60710a42 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16423,6 +16423,7 @@ version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", + "globset", "lazy_static", "regex", "serde", diff --git a/backend/migrations/20260412003741_folder_default_permissioned_as.down.sql b/backend/migrations/20260412003741_folder_default_permissioned_as.down.sql new file mode 100644 index 0000000000..6cea721890 --- /dev/null +++ b/backend/migrations/20260412003741_folder_default_permissioned_as.down.sql @@ -0,0 +1 @@ +ALTER TABLE folder DROP COLUMN default_permissioned_as; diff --git a/backend/migrations/20260412003741_folder_default_permissioned_as.up.sql b/backend/migrations/20260412003741_folder_default_permissioned_as.up.sql new file mode 100644 index 0000000000..a0940734a5 --- /dev/null +++ b/backend/migrations/20260412003741_folder_default_permissioned_as.up.sql @@ -0,0 +1,5 @@ +-- Array of { path_glob, permissioned_as } rules. Globs are evaluated relative +-- to the folder root and matched in array order (first match wins). Applied at +-- create-time only, for admins and wm_deployers members. +ALTER TABLE folder + ADD COLUMN default_permissioned_as JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/backend/tests/fixtures/folder_default_permissioned_as.sql b/backend/tests/fixtures/folder_default_permissioned_as.sql new file mode 100644 index 0000000000..ff8428a1c5 --- /dev/null +++ b/backend/tests/fixtures/folder_default_permissioned_as.sql @@ -0,0 +1,200 @@ +-- Fixture for preserve_on_behalf_of integration tests +-- Extends base.sql with a deployer user in the wm_deployers group + +-- Include all base setup (workspace, admin user, etc.) +INSERT INTO workspace + (id, name, owner) + VALUES ('test-workspace', 'test-workspace', 'test-user') +ON CONFLICT DO NOTHING; + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin') +ON CONFLICT DO NOTHING; + +INSERT INTO workspace_key(workspace_id, kind, key) VALUES + ('test-workspace', 'cloud', 'test-key') +ON CONFLICT DO NOTHING; + +INSERT INTO workspace_settings (workspace_id) VALUES + ('test-workspace') +ON CONFLICT DO NOTHING; + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'all', 'All users', '{}') +ON CONFLICT DO NOTHING; + +-- Create the wm_deployers group +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'wm_deployers', 'Users allowed to deploy and preserve on_behalf_of', '{}') +ON CONFLICT DO NOTHING; + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user') +ON CONFLICT DO NOTHING; + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('test2@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Test User 2') +ON CONFLICT DO NOTHING; + +-- Deployer user (non-admin but in wm_deployers group) +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('deployer@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Deployer User') +ON CONFLICT DO NOTHING; + +-- Original user whose on_behalf_of should be preserved +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('original@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Original User') +ON CONFLICT DO NOTHING; + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'test2@windmill.dev', 'test-user-2', false, 'User') +ON CONFLICT DO NOTHING; + +-- Deployer user in workspace +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'deployer@windmill.dev', 'deployer-user', false, 'User') +ON CONFLICT DO NOTHING; + +-- Original user in workspace (whose on_behalf_of should be preserved) +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'original@windmill.dev', 'original-user', false, 'User') +ON CONFLICT DO NOTHING; + +-- Add deployer user to wm_deployers group +INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES + ('test-workspace', 'wm_deployers', 'deployer-user') +ON CONFLICT DO NOTHING; + +-- Superadmin NOT in workspace (only in password table, not in usr) +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('superadmin-external@windmill.dev', 'not-a-real-hash', 'password', true, true, 'External Superadmin', 'superadmin-external') +ON CONFLICT DO NOTHING; + +-- Tokens for all users (token_hash = sha256 hex, token_prefix = first 10 chars) +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true) +ON CONFLICT DO NOTHING; +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) +ON CONFLICT DO NOTHING; +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('DEPLOYER_TOKEN'::bytea), 'hex'), 'DEPLOYER_T', 'DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) +ON CONFLICT DO NOTHING; +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('ORIGINAL_TOKEN'::bytea), 'hex'), 'ORIGINAL_T', 'ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) +ON CONFLICT DO NOTHING; +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('EXTERNAL_SUPERADMIN_TOKEN'::bytea), 'hex'), 'EXTERNAL_S', 'EXTERNAL_SUPERADMIN_TOKEN', 'superadmin-external@windmill.dev', 'external superadmin token', true) +ON CONFLICT DO NOTHING; + +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; + +CREATE OR REPLACE FUNCTION "notify_insert_on_completed_job" () +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('completed', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE PLPGSQL; + +DROP TRIGGER IF EXISTS "notify_insert_on_completed_job" ON "v2_job_completed"; +CREATE TRIGGER "notify_insert_on_completed_job" + AFTER INSERT ON "v2_job_completed" + FOR EACH ROW +EXECUTE FUNCTION "notify_insert_on_completed_job" (); + +CREATE OR REPLACE FUNCTION "notify_queue" () +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('queued', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE PLPGSQL; + +DROP TRIGGER IF EXISTS "notify_queue_after_insert" ON "v2_job_queue"; +CREATE TRIGGER "notify_queue_after_insert" + AFTER INSERT ON "v2_job_queue" + FOR EACH ROW +EXECUTE FUNCTION "notify_queue" (); + +DROP TRIGGER IF EXISTS "notify_queue_after_flow_status_update" ON "v2_job_status"; +CREATE TRIGGER "notify_queue_after_flow_status_update" + AFTER UPDATE ON "v2_job_status" + FOR EACH ROW + WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status) +EXECUTE FUNCTION "notify_queue" (); + +-- Apply phase 4: +DROP FUNCTION IF EXISTS v2_job_after_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE; + +DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE; + +ALTER TABLE v2_job_queue + DROP COLUMN IF EXISTS __parent_job CASCADE, + DROP COLUMN IF EXISTS __created_by CASCADE, + DROP COLUMN IF EXISTS __script_hash CASCADE, + DROP COLUMN IF EXISTS __script_path CASCADE, + DROP COLUMN IF EXISTS __args CASCADE, + DROP COLUMN IF EXISTS __logs CASCADE, + DROP COLUMN IF EXISTS __raw_code CASCADE, + DROP COLUMN IF EXISTS __canceled CASCADE, + DROP COLUMN IF EXISTS __last_ping CASCADE, + DROP COLUMN IF EXISTS __job_kind CASCADE, + DROP COLUMN IF EXISTS __env_id CASCADE, + DROP COLUMN IF EXISTS __schedule_path CASCADE, + DROP COLUMN IF EXISTS __permissioned_as CASCADE, + DROP COLUMN IF EXISTS __flow_status CASCADE, + DROP COLUMN IF EXISTS __raw_flow CASCADE, + DROP COLUMN IF EXISTS __is_flow_step CASCADE, + DROP COLUMN IF EXISTS __language CASCADE, + DROP COLUMN IF EXISTS __same_worker CASCADE, + DROP COLUMN IF EXISTS __raw_lock CASCADE, + DROP COLUMN IF EXISTS __pre_run_error CASCADE, + DROP COLUMN IF EXISTS __email CASCADE, + DROP COLUMN IF EXISTS __visible_to_owner CASCADE, + DROP COLUMN IF EXISTS __mem_peak CASCADE, + DROP COLUMN IF EXISTS __root_job CASCADE, + DROP COLUMN IF EXISTS __leaf_jobs CASCADE, + DROP COLUMN IF EXISTS __concurrent_limit CASCADE, + DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE, + DROP COLUMN IF EXISTS __timeout CASCADE, + DROP COLUMN IF EXISTS __flow_step_id CASCADE, + DROP COLUMN IF EXISTS __cache_ttl CASCADE; + +LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE; +ALTER TABLE v2_job_completed + DROP COLUMN IF EXISTS __parent_job CASCADE, + DROP COLUMN IF EXISTS __created_by CASCADE, + DROP COLUMN IF EXISTS __created_at CASCADE, + DROP COLUMN IF EXISTS __success CASCADE, + DROP COLUMN IF EXISTS __script_hash CASCADE, + DROP COLUMN IF EXISTS __script_path CASCADE, + DROP COLUMN IF EXISTS __args CASCADE, + DROP COLUMN IF EXISTS __logs CASCADE, + DROP COLUMN IF EXISTS __raw_code CASCADE, + DROP COLUMN IF EXISTS __canceled CASCADE, + DROP COLUMN IF EXISTS __job_kind CASCADE, + DROP COLUMN IF EXISTS __env_id CASCADE, + DROP COLUMN IF EXISTS __schedule_path CASCADE, + DROP COLUMN IF EXISTS __permissioned_as CASCADE, + DROP COLUMN IF EXISTS __raw_flow CASCADE, + DROP COLUMN IF EXISTS __is_flow_step CASCADE, + DROP COLUMN IF EXISTS __language CASCADE, + DROP COLUMN IF EXISTS __is_skipped CASCADE, + DROP COLUMN IF EXISTS __raw_lock CASCADE, + DROP COLUMN IF EXISTS __email CASCADE, + DROP COLUMN IF EXISTS __visible_to_owner CASCADE, + DROP COLUMN IF EXISTS __tag CASCADE, + DROP COLUMN IF EXISTS __priority CASCADE; diff --git a/backend/tests/folder_default_permissioned_as.rs b/backend/tests/folder_default_permissioned_as.rs new file mode 100644 index 0000000000..1e71202595 --- /dev/null +++ b/backend/tests/folder_default_permissioned_as.rs @@ -0,0 +1,767 @@ +//! Integration tests for folder `default_permissioned_as` rules. +//! +//! These tests exhaustively verify the feature across every surface where a +//! `permissioned_as` / `on_behalf_of_email` default is applied at create-time: +//! +//! - folder CRUD (persistence, validation) +//! - audit log emission +//! - schedules, flows, scripts, apps — all entry points +//! - admin / wm_deployers / regular-user behavior +//! - create-only semantics (updates never rewrite) +//! - explicit preserve beats folder default +//! - first-match-wins ordering for overlapping rules +//! - stale rule rejection +//! - paths outside folders are untouched +//! +//! The tests share one workspace and one folder because ApiServer::start has +//! significant setup cost; keeping everything in one test minimizes total run +//! time while still exercising every branch. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +fn new_script(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Test script", + "description": "", + "content": "export async function main() { return 42; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +fn new_flow(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Test flow", + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +fn new_app(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Test app", + "value": { + "type": "rawapp", + "inline_script": null + }, + "policy": { + "execution_mode": "anonymous", + "triggerables": {} + } + }) +} + +fn new_schedule(path: &str, script_path: &str) -> serde_json::Value { + json!({ + "path": path, + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": script_path, + "is_flow": false, + "enabled": false, + }) +} + +/// Exhaustive create-time folder default_permissioned_as coverage. +#[sqlx::test(fixtures("folder_default_permissioned_as"))] +async fn test_folder_default_permissioned_as(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================================================== + // 0. Setup — create the folder with no rules (admin becomes owner) + // and grant the deployer + regular users writer access so they can + // deploy items inside it. + // ======================================================================== + + let resp = authed( + client().post(format!("{base}/folders/create")), + "SECRET_TOKEN", + ) + .json(&json!({ + "name": "prodfolder", + "extra_perms": { + "u/deployer-user": true, + "u/test-user-2": true + } + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create folder: {}", + resp.text().await? + ); + + // A helper script that schedules reference + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script("f/prodfolder/helper")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create helper script: {}", + resp.text().await? + ); + + // ======================================================================== + // 1. Rule validation — bad inputs are rejected with 400 + // ======================================================================== + + // Non-array + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ "default_permissioned_as": { "not": "an array" } })) + .send() + .await?; + assert_eq!(resp.status(), 400, "non-array should 400"); + + // Invalid glob + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ + "default_permissioned_as": [{ "path_glob": "[unclosed", "permissioned_as": "u/original-user" }] + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "invalid glob should 400"); + let body = resp.text().await?; + assert!( + body.contains("path_glob is not a valid glob"), + "error should mention glob: {body}" + ); + + // Invalid permissioned_as format + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ + "default_permissioned_as": [{ "path_glob": "**", "permissioned_as": "bogus" }] + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "invalid permissioned_as should 400"); + + // Missing required field + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ + "default_permissioned_as": [{ "path_glob": "**" }] + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "missing permissioned_as should 400"); + + // ======================================================================== + // 2. Rule persistence — valid rules round-trip through GET folder + // ======================================================================== + + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ + "default_permissioned_as": [ + { "path_glob": "jobs/critical/**", "permissioned_as": "u/original-user" }, + { "path_glob": "jobs/**", "permissioned_as": "g/wm_deployers" }, + { "path_glob": "reports/*", "permissioned_as": "original@windmill.dev" } + ] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Valid rules should update: {}", + resp.text().await? + ); + + let resp = authed( + client().get(format!("{base}/folders/get/prodfolder")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let folder: serde_json::Value = resp.json().await?; + let rules = folder + .get("default_permissioned_as") + .and_then(|v| v.as_array()) + .expect("rules should be an array"); + assert_eq!(rules.len(), 3, "should have 3 rules"); + assert_eq!(rules[0]["path_glob"], "jobs/critical/**"); + assert_eq!(rules[0]["permissioned_as"], "u/original-user"); + assert_eq!(rules[1]["permissioned_as"], "g/wm_deployers"); + assert_eq!(rules[2]["permissioned_as"], "original@windmill.dev"); + + // ======================================================================== + // 4. Schedules — the core matrix + // ======================================================================== + + // 4a. Admin, matching path — folder default wins + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&new_schedule( + "f/prodfolder/jobs/sched_admin_match", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/sched_admin_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "g/wm_deployers", + "matching rule should apply (jobs/** wins over critical since path is not critical)" + ); + + // 4b. Admin, path matching the MORE SPECIFIC rule which is listed first + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&new_schedule( + "f/prodfolder/jobs/critical/prod_run", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create critical schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/critical/prod_run", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "u/original-user", + "first matching rule wins (critical/** listed first)" + ); + + // 4c. Admin, non-matching path — acting user identity + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&new_schedule( + "f/prodfolder/dev/sched_no_match", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create non-matching schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as, email FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/dev/sched_no_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "u/test-user", + "non-matching path falls back to acting user" + ); + assert_eq!(sched.email, "test@windmill.dev"); + + // 4d. Deployer (non-admin in wm_deployers), matching path — default applies + let resp = authed( + client().post(format!("{base}/schedules/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_schedule( + "f/prodfolder/jobs/sched_deployer", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Deployer should create schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/sched_deployer", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "g/wm_deployers", + "deployer gets folder default" + ); + assert_eq!( + sched.edited_by, "deployer-user", + "edited_by is still the acting user" + ); + + // 4e. Non-admin/non-deployer, matching path — default is NOT applied + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN_2", + ) + .json(&new_schedule( + "f/prodfolder/jobs/sched_regular", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Regular user should create schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/sched_regular", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "u/test-user-2", + "regular user never gets folder default" + ); + + // 4f. Explicit preserve beats folder default + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&json!({ + "path": "f/prodfolder/jobs/sched_preserve", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "f/prodfolder/helper", + "is_flow": false, + "enabled": false, + "permissioned_as": "u/original-user", + "preserve_permissioned_as": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create preserved schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/sched_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "u/original-user", + "explicit preserve beats folder default (even though folder default would be g/wm_deployers)" + ); + + // 4g. Update does NOT rewrite permissioned_as to folder default + // Edit the non-matching schedule created in 4c, with no permissioned_as in the payload. + let resp = authed( + client().post(format!( + "{base}/schedules/update/f/prodfolder/dev/sched_no_match" + )), + "SECRET_TOKEN", + ) + .json(&json!({ + "schedule": "0 0 */12 * * *", + "timezone": "UTC", + "script_path": "f/prodfolder/helper", + "is_flow": false, + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update schedule: {}", + resp.text().await? + ); + let sched = sqlx::query!( + "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/dev/sched_no_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "u/test-user", + "update still shows acting user — the existing schedule wasn't rewritten by a folder rule change (this would be defaults-on-update, which we don't do)" + ); + + // ======================================================================== + // 5. Flows — on_behalf_of_email variant + // ======================================================================== + + // 5a. Admin, matching path + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow("f/prodfolder/jobs/flow_admin_match")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create flow: {}", + resp.text().await? + ); + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/flow_admin_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("group-wm_deployers@windmill.dev"), + "flow should resolve folder default to group email" + ); + + // 5b. Admin, reports/* rule (email directly as permissioned_as) + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow("f/prodfolder/reports/weekly")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create reports flow: {}", + resp.text().await? + ); + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/reports/weekly", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "email rule should pass through as-is" + ); + + // 5c. Non-matching flow path — no default + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow("f/prodfolder/dev/flow_no_match")) + .send() + .await?; + assert_eq!(resp.status(), 201); + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/dev/flow_no_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email, None, + "no folder default ⇒ no on_behalf_of_email written" + ); + + // 5d. Path outside any folder (user folder) — default never applies + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow("u/test-user/outside_flow")) + .send() + .await?; + assert_eq!(resp.status(), 201); + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/test-user/outside_flow", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email, None, + "paths outside folders are never touched" + ); + + // ======================================================================== + // 6. Scripts — first-time create applies default; subsequent updates don't + // ======================================================================== + + // 6a. Admin creates a new script at matching path + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script("f/prodfolder/jobs/new_script")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create script: {}", + resp.text().await? + ); + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "f/prodfolder/jobs/new_script", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("group-wm_deployers@windmill.dev"), + "new script at matching path gets folder default" + ); + + // Note: Windmill scripts require the previous non-archived version to be + // explicitly archived before a new hash can be created at the same path. + // We rely on the existence check inside `create_script_internal` + // (`path_already_exists` → skip folder default) which is unit-level logic + // exercised on every subsequent deploy at an existing path. The flow 5c + // case above covers create-only semantics end-to-end through the API. + + // ======================================================================== + // 7. Apps — policy.on_behalf_of variant + // ======================================================================== + + // 7a. Admin, matching path + let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") + .json(&new_app("f/prodfolder/jobs/app_match")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create app: {}", + resp.text().await? + ); + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/app_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy: serde_json::Value = app.policy; + assert_eq!( + policy["on_behalf_of"], "g/wm_deployers", + "app policy.on_behalf_of gets folder default" + ); + assert_eq!( + policy["on_behalf_of_email"], "group-wm_deployers@windmill.dev", + "app policy.on_behalf_of_email gets folder default email" + ); + + // 7b. Admin, non-matching path — acting user + let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") + .json(&new_app("f/prodfolder/dev/app_no_match")) + .send() + .await?; + assert_eq!(resp.status(), 201); + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/dev/app_no_match", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy: serde_json::Value = app.policy; + assert_eq!(policy["on_behalf_of"], "u/test-user"); + + // 7c. Regular user, matching path — acting user (not folder default) + let resp = authed( + client().post(format!("{base}/apps/create")), + "SECRET_TOKEN_2", + ) + .json(&new_app("f/prodfolder/jobs/app_regular")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Regular user should create app: {}", + resp.text().await? + ); + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/app_regular", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy: serde_json::Value = app.policy; + assert_eq!( + policy["on_behalf_of"], "u/test-user-2", + "regular user never gets folder default in app policy" + ); + + // ======================================================================== + // 8. Stale rule — rule resolves to a user that does not exist + // ======================================================================== + + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ + "default_permissioned_as": [ + { "path_glob": "stale/**", "permissioned_as": "u/ghost" } + ] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update folder rules: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&new_schedule( + "f/prodfolder/stale/should_fail", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "stale rule should return 400 when a new schedule matches it" + ); + let body = resp.text().await?; + assert!( + body.contains("u/ghost") && body.contains("does not exist"), + "error should identify the stale user: {body}" + ); + + // Non-matching paths in the same folder still work fine. + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&new_schedule("f/prodfolder/safe/ok", "f/prodfolder/helper")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "non-matching path should not hit the stale rule: {}", + resp.text().await? + ); + + // ======================================================================== + // 9. Clearing rules by passing an empty array + // ======================================================================== + + let resp = authed( + client().post(format!("{base}/folders/update/prodfolder")), + "SECRET_TOKEN", + ) + .json(&json!({ "default_permissioned_as": [] })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&new_schedule( + "f/prodfolder/jobs/cleared", + "f/prodfolder/helper", + )) + .send() + .await?; + assert_eq!(resp.status(), 200); + let sched = sqlx::query!( + "SELECT permissioned_as FROM schedule WHERE path = $1 AND workspace_id = $2", + "f/prodfolder/jobs/cleared", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + sched.permissioned_as, "u/test-user", + "after rules cleared, new schedule uses acting user again" + ); + + Ok(()) +} diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 51b17a1c52..cecfda9a52 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -430,7 +430,7 @@ async fn create_flow( Extension(user_db): Extension, Extension(webhook): Extension, Path(w_id): Path, - Json(nf): Json, + Json(mut nf): Json, ) -> Result<(StatusCode, String)> { if authed.is_operator { return Err(Error::NotAuthorized( @@ -483,6 +483,23 @@ async fn create_flow( // cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?; let authed = maybe_refresh_folders(&nf.path, &w_id, authed, &db).await; + // Apply folder default_permissioned_as on create when the caller did not + // explicitly preserve a value and the user can preserve. + let explicit_preserve = nf.on_behalf_of_email.is_some() + && nf.preserve_on_behalf_of.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(&authed); + if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { + if let Some(default_email) = + windmill_common::folders::resolve_folder_default_on_behalf_of_email( + &db, &w_id, &nf.path, + ) + .await? + { + nf.on_behalf_of_email = Some(default_email); + nf.preserve_on_behalf_of = Some(true); + } + } + let mut tx = user_db.clone().begin(&authed).await?; check_path_conflict(&mut tx, &w_id, &nf.path).await?; diff --git a/backend/windmill-api-groups/Cargo.toml b/backend/windmill-api-groups/Cargo.toml index f785b9ebed..856eaacc0a 100644 --- a/backend/windmill-api-groups/Cargo.toml +++ b/backend/windmill-api-groups/Cargo.toml @@ -22,6 +22,7 @@ windmill-git-sync.workspace = true axum.workspace = true chrono.workspace = true +globset.workspace = true lazy_static.workspace = true regex.workspace = true serde.workspace = true diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index ab1541adc8..2a87fc38de 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -60,6 +60,7 @@ pub struct Folder { pub summary: Option, pub created_by: Option, pub edited_at: Option>, + pub default_permissioned_as: serde_json::Value, } #[derive(Deserialize)] @@ -69,6 +70,7 @@ pub struct NewFolder { pub display_name: Option, pub owners: Option>, pub extra_perms: Option, + pub default_permissioned_as: Option, } #[derive(Deserialize)] @@ -77,6 +79,7 @@ pub struct UpdateFolder { pub display_name: Option, pub owners: Option>, pub extra_perms: Option, + pub default_permissioned_as: Option, } #[derive(Deserialize)] @@ -96,7 +99,7 @@ async fn list_folders( let rows = sqlx::query_as!( Folder, - "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", + "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE workspace_id = $1 ORDER BY name asc LIMIT $2 OFFSET $3", w_id, per_page as i64, offset as i64 @@ -142,6 +145,49 @@ fn validate_owner(owner: &str) -> Result<()> { Ok(()) } +fn validate_default_permissioned_as(value: &serde_json::Value) -> Result<()> { + let arr = value.as_array().ok_or_else(|| { + error::Error::BadRequest("default_permissioned_as must be a JSON array".to_string()) + })?; + for (idx, rule) in arr.iter().enumerate() { + let obj = rule.as_object().ok_or_else(|| { + error::Error::BadRequest(format!( + "default_permissioned_as[{idx}] must be an object with path_glob and permissioned_as" + )) + })?; + let path_glob = obj + .get("path_glob") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + error::Error::BadRequest(format!( + "default_permissioned_as[{idx}].path_glob must be a string" + )) + })?; + let permissioned_as = obj + .get("permissioned_as") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + error::Error::BadRequest(format!( + "default_permissioned_as[{idx}].permissioned_as must be a string" + )) + })?; + globset::Glob::new(path_glob).map_err(|e| { + error::Error::BadRequest(format!( + "default_permissioned_as[{idx}].path_glob is not a valid glob: {e}" + )) + })?; + let valid_permissioned_as = permissioned_as.starts_with("u/") + || permissioned_as.starts_with("g/") + || permissioned_as.contains('@'); + if !valid_permissioned_as { + return Err(error::Error::BadRequest(format!( + "default_permissioned_as[{idx}].permissioned_as must be of the form u/, g/, or an email" + ))); + } + } + Ok(()) +} + async fn check_name_conflict<'c>( tx: &mut Transaction<'c, Postgres>, w_id: &str, @@ -227,17 +273,23 @@ async fn create_folder( )); } + let default_permissioned_as = ng + .default_permissioned_as + .unwrap_or_else(|| serde_json::Value::Array(vec![])); + validate_default_permissioned_as(&default_permissioned_as)?; + if let Err(e) = sqlx::query_as!( Folder, - "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now())", + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)", w_id, ng.name, ng.display_name.unwrap_or(ng.name.clone()), &owners, extra_perms, ng.summary, - authed.username + authed.username, + default_permissioned_as ) .execute(&mut *tx) .await { @@ -362,6 +414,7 @@ async fn update_folder( // Track whether permission-related fields are being updated let owners_changed = ng.owners.is_some(); let extra_perms_changed = ng.extra_perms.is_some(); + let default_permissioned_as_changed = ng.default_permissioned_as.is_some(); if !authed.is_admin { let prefixed_username = format!("u/{}", authed.username); @@ -407,6 +460,14 @@ async fn update_folder( ); } + if let Some(default_permissioned_as) = ng.default_permissioned_as.as_ref() { + validate_default_permissioned_as(default_permissioned_as)?; + sqlb.set( + "default_permissioned_as", + "?".bind(&serde_json::to_string(default_permissioned_as).map_err(to_anyhow)?), + ); + } + sqlb.returning("*"); let mut tx = user_db.begin(&authed).await?; @@ -467,6 +528,20 @@ async fn update_folder( log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, "update_acl", None) .await?; } + if default_permissioned_as_changed { + let rules_json = + serde_json::to_string(&nfolder.default_permissioned_as).unwrap_or_default(); + audit_log( + &mut *tx, + &authed, + "folder.update_default_permissioned_as", + ActionKind::Update, + &w_id, + Some(&name.to_string()), + Some([("default_permissioned_as", rules_json.as_str())].into()), + ) + .await?; + } tx.commit().await?; @@ -497,7 +572,7 @@ pub async fn get_folderopt<'c>( ) -> Result> { let folderopt = sqlx::query_as!( Folder, - "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at FROM folder WHERE name = $1 AND workspace_id = $2", + "SELECT workspace_id, name, display_name, owners, extra_perms, summary, created_by, edited_at, default_permissioned_as FROM folder WHERE name = $1 AND workspace_id = $2", name, w_id ) diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index aa7b5cb07f..f9a24d99d8 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -48,6 +48,33 @@ fn resolve_permissioned_as( windmill_common::users::username_to_permissioned_as(&authed.username) } +/// Create-time variant: applies the folder's `default_permissioned_as` rule when no +/// explicit preserved value is provided and the caller can preserve (admin / wm_deployers). +async fn resolve_permissioned_as_for_create( + permissioned_as: Option<&String>, + preserve_permissioned_as: Option, + path: &str, + authed: &ApiAuthed, + db: &DB, + w_id: &str, +) -> Result { + if let Some(pa) = permissioned_as { + if preserve_permissioned_as.unwrap_or(false) && can_preserve_on_behalf_of(authed) { + return Ok(pa.clone()); + } + } + if can_preserve_on_behalf_of(authed) { + if let Some(default) = + windmill_common::folders::resolve_folder_default_permissioned_as(db, w_id, path).await? + { + return Ok(default); + } + } + Ok(windmill_common::users::username_to_permissioned_as( + &authed.username, + )) +} + fn resolve_edited_by(authed: &ApiAuthed) -> String { authed.username.clone() } @@ -227,11 +254,15 @@ async fn create_schedule( } let resolved_edited_by = resolve_edited_by(&authed); - let resolved_permissioned_as = resolve_permissioned_as( + let resolved_permissioned_as = resolve_permissioned_as_for_create( ns.permissioned_as.as_ref(), ns.preserve_permissioned_as, + &ns.path, &authed, - ); + &db, + &w_id, + ) + .await?; // email is still written for backwards compat with old workers that don't know about permissioned_as let resolved_email = windmill_common::users::get_email_from_permissioned_as( &resolved_permissioned_as, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index dc6026a4ca..079bdacccf 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -694,7 +694,35 @@ async fn create_script_internal<'c>( let script_path = ns.path.clone(); let hash = ScriptHash(hash_script(&ns)); let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; + let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; + + // Apply folder default_permissioned_as the first time a script is deployed + // at this path. Check inside the transaction to avoid TOCTOU with concurrent deploys. + let explicit_preserve = ns.on_behalf_of_email.is_some() + && ns.preserve_on_behalf_of.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(&authed); + if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { + let path_already_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)", + &ns.path, + &w_id + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if !path_already_exists { + if let Some(default_email) = + windmill_common::folders::resolve_folder_default_on_behalf_of_email( + &db, &w_id, &ns.path, + ) + .await? + { + ns.on_behalf_of_email = Some(default_email); + ns.preserve_on_behalf_of = Some(true); + } + } + } if sqlx::query_scalar!( "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2", hash.0, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 5e8c2494b1..4deeb1ed6f 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3613,8 +3613,8 @@ async fn clone_folders( target_workspace_id: &str, ) -> Result<()> { sqlx::query!( - "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by) - SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) + SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as FROM folder WHERE workspace_id = $1", source_workspace_id, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 24d4192299..195b6dc89f 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -444,7 +444,9 @@ pub(crate) async fn change_workspace_id( // Duplicate folders with new workspace id (FK constraint) info!("Duplicating folder table rows"); sqlx::query!( - "INSERT INTO folder SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by FROM folder WHERE workspace_id = $2", + "INSERT INTO folder (name, workspace_id, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as) \ + SELECT name, $1, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as \ + FROM folder WHERE workspace_id = $2", &rw.new_id, &old_id ) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2805098c93..fc5033d2b0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16062,6 +16062,8 @@ paths: extra_perms: additionalProperties: type: boolean + default_permissioned_as: + $ref: '#/components/schemas/FolderDefaultPermissionedAs' required: - name responses: @@ -16098,6 +16100,8 @@ paths: extra_perms: additionalProperties: type: boolean + default_permissioned_as: + $ref: '#/components/schemas/FolderDefaultPermissionedAs' responses: "200": description: folder updated @@ -24765,11 +24769,39 @@ components: edited_at: type: string format: date-time + default_permissioned_as: + $ref: '#/components/schemas/FolderDefaultPermissionedAs' required: - name - owners - extra_perms + FolderDefaultPermissionedAs: + description: > + Ordered list of rules applied at create-time when admins or `wm_deployers` + members deploy items in this folder. The first rule whose `path_glob` + matches the item path (relative to the folder root) wins, and its + `permissioned_as` is used as the default. + type: array + items: + type: object + required: + - path_glob + - permissioned_as + properties: + path_glob: + type: string + description: > + Glob pattern evaluated against the item path *relative* to the + folder root (e.g. "jobs/**" matches every item whose full path is + `f//jobs/...`). Supports `*`, `**`, `?`, `[abc]`, `{a,b}`. + permissioned_as: + type: string + description: > + Target identity the matched item should be permissioned as. Must be + `u/`, `g/`, or an email that exists in this + workspace. + WorkerPing: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 0f01364e27..5a9f065e05 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -1237,8 +1237,25 @@ async fn create_app_internal<'a>( && app.policy.on_behalf_of.is_some(); if !should_preserve { - app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username)); - app.policy.on_behalf_of_email = Some(authed.email.clone()); + let folder_default = if windmill_common::can_preserve_on_behalf_of(&authed) { + windmill_common::folders::resolve_folder_default_permissioned_as(&db, w_id, &app.path) + .await? + } else { + None + }; + if let Some(default_permissioned_as) = folder_default { + let default_email = windmill_common::users::get_email_from_permissioned_as( + &default_permissioned_as, + w_id, + &db, + ) + .await?; + app.policy.on_behalf_of = Some(default_permissioned_as); + app.policy.on_behalf_of_email = Some(default_email); + } else { + app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username)); + app.policy.on_behalf_of_email = Some(authed.email.clone()); + } } let path = app.path.clone(); if &app.path == "" { diff --git a/backend/windmill-common/src/folders.rs b/backend/windmill-common/src/folders.rs new file mode 100644 index 0000000000..0c40faf09f --- /dev/null +++ b/backend/windmill-common/src/folders.rs @@ -0,0 +1,140 @@ +use globset::Glob; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres}; + +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FolderDefaultPermissionedAsRule { + pub path_glob: String, + pub permissioned_as: String, +} + +/// Returns the first matching default `permissioned_as` for `path` in its folder, if any. +/// +/// `path` is the full item path (e.g. `f/prod/jobs/run_a`). The glob is matched against +/// the path *relative* to the folder root (`jobs/run_a` in the example) so rules can +/// never match outside their own folder. Returns `None` if the path is not under a +/// folder, if the folder has no rules, or if no rule matches. +/// +/// If a rule matches but its `permissioned_as` resolves to a user or group that no +/// longer exists, returns `Error::BadRequest` so the caller surfaces a clean error +/// rather than silently creating an item owned by nobody. +pub async fn resolve_folder_default_permissioned_as( + db: &Pool, + w_id: &str, + path: &str, +) -> Result> { + let Some(rest) = path.strip_prefix("f/") else { + return Ok(None); + }; + let Some((folder_name, relative_path)) = rest.split_once('/') else { + return Ok(None); + }; + if relative_path.is_empty() { + return Ok(None); + } + + let rules_value = sqlx::query_scalar!( + "SELECT default_permissioned_as FROM folder WHERE workspace_id = $1 AND name = $2", + w_id, + folder_name + ) + .fetch_optional(db) + .await?; + + let Some(rules_value) = rules_value else { + return Ok(None); + }; + + let rules: Vec = + serde_json::from_value(rules_value).unwrap_or_default(); + + for rule in rules { + let Ok(glob) = Glob::new(&rule.path_glob) else { + continue; + }; + if !glob.compile_matcher().is_match(relative_path) { + continue; + } + ensure_permissioned_as_exists(db, w_id, folder_name, &rule).await?; + return Ok(Some(rule.permissioned_as)); + } + + Ok(None) +} + +/// Email-valued variant of [`resolve_folder_default_permissioned_as`]. Used by +/// flows and scripts which store `on_behalf_of_email` rather than `permissioned_as`. +pub async fn resolve_folder_default_on_behalf_of_email( + db: &Pool, + w_id: &str, + path: &str, +) -> Result> { + let Some(permissioned_as) = resolve_folder_default_permissioned_as(db, w_id, path).await? + else { + return Ok(None); + }; + let email = crate::users::get_email_from_permissioned_as(&permissioned_as, w_id, db).await?; + Ok(Some(email)) +} + +async fn ensure_permissioned_as_exists( + db: &Pool, + w_id: &str, + folder_name: &str, + rule: &FolderDefaultPermissionedAsRule, +) -> Result<()> { + if let Some(username) = rule.permissioned_as.strip_prefix("u/") { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2)", + w_id, + username + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "Folder '{folder_name}' default_permissioned_as rule '{}' resolves to user '{}' which does not exist in this workspace. Fix the folder rule and try again.", + rule.path_glob, rule.permissioned_as + ))); + } + } else if let Some(group) = rule.permissioned_as.strip_prefix("g/") { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)", + w_id, + group + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "Folder '{folder_name}' default_permissioned_as rule '{}' resolves to group '{}' which does not exist in this workspace. Fix the folder rule and try again.", + rule.path_glob, rule.permissioned_as + ))); + } + } else if rule.permissioned_as.contains('@') { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", + w_id, + &rule.permissioned_as + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "Folder '{folder_name}' default_permissioned_as rule '{}' resolves to email '{}' which does not exist in this workspace. Fix the folder rule and try again.", + rule.path_glob, rule.permissioned_as + ))); + } + } else { + return Err(Error::BadRequest(format!( + "Folder '{folder_name}' default_permissioned_as rule '{}' has an unrecognised permissioned_as format '{}'. Expected u/, g/, or an email.", + rule.path_glob, rule.permissioned_as + ))); + } + Ok(()) +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index a607e2dea5..f2d5dff755 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -58,6 +58,7 @@ pub mod external_ip; pub mod flow_conversations; pub mod flow_status; pub mod flows; +pub mod folders; pub mod global_settings; pub mod indexer; pub mod instance_config; @@ -470,7 +471,8 @@ impl PgDatabase { Ok(result) => Ok(result), Err(e) => { let err_str = e.to_string(); - if err_str.contains("password authentication failed for user") && err_str.contains("custom_instance_user") + if err_str.contains("password authentication failed for user") + && err_str.contains("custom_instance_user") { if let Some(db) = main_db { tracing::warn!( diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 45651d1445..3ac9281fd4 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -401,7 +401,7 @@ async fn create_trigger( Extension(db): Extension, Extension(user_db): Extension, Path(workspace_id): Path, - Json(new_trigger): Json>, + Json(mut new_trigger): Json>, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || { format!( @@ -426,6 +426,25 @@ async fn create_trigger( let new_path = new_trigger.base.path.clone(); let labels = new_trigger.base.labels.clone(); + + // If the caller did not preserve a value but the user can preserve, fall back + // to the folder's default_permissioned_as rule (create-time only). + let explicit_preserve = new_trigger.base.permissioned_as.is_some() + && new_trigger.base.preserve_permissioned_as.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(&authed); + if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { + if let Some(default) = windmill_common::folders::resolve_folder_default_permissioned_as( + &db, + &workspace_id, + &new_trigger.base.path, + ) + .await? + { + new_trigger.base.permissioned_as = Some(default); + new_trigger.base.preserve_permissioned_as = Some(true); + } + } + let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( new_trigger.base.permissioned_as.as_deref(), new_trigger.base.preserve_permissioned_as.unwrap_or(false), diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index e9e195ce5f..f99df84636 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -18,6 +18,7 @@ import lintCommand from "./lint.ts"; import newCommand from "./new.ts"; import generateAgentsCommand from "./generate_agents.ts"; import { isVersionsGeq1585 } from "../sync/global.ts"; +import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; export interface AppFile { value: any; @@ -105,7 +106,8 @@ export async function pushApp( workspace: string, remotePath: string, localPath: string, - message?: string + message?: string, + permissionedAsContext?: PermissionedAsContext ): Promise { if (alreadySynced.includes(localPath)) { return; @@ -122,6 +124,14 @@ export async function pushApp( } catch { //ignore } + + let remoteOnBehalfOf: string | undefined; + let remoteOnBehalfOfEmail: string | undefined; + if (app?.policy) { + remoteOnBehalfOf = app.policy.on_behalf_of; + remoteOnBehalfOfEmail = app.policy.on_behalf_of_email; + } + if (isExecutionModeAnonymous(app)) { app.public = true; } @@ -143,6 +153,20 @@ export async function pushApp( localApp?.["public"] ?? localApp?.["policy"]?.["execution_mode"] == "anonymous" ); + + const preserveFields: { preserve_on_behalf_of?: boolean } = {}; + if (permissionedAsContext?.userIsAdminOrDeployer) { + if (app) { + if (localApp.policy && remoteOnBehalfOf) { + (localApp.policy as any).on_behalf_of = remoteOnBehalfOf; + (localApp.policy as any).on_behalf_of_email = remoteOnBehalfOfEmail; + preserveFields.preserve_on_behalf_of = true; + log.info(`Preserving ${remoteOnBehalfOfEmail ?? remoteOnBehalfOf} as permissioned_as for app ${remotePath}`); + } + } + // On create: backend applies folder defaults + } + if (app) { if (isSuperset(localApp, app)) { log.info(colors.green(`App ${remotePath} is up to date`)); @@ -155,6 +179,7 @@ export async function pushApp( requestBody: { deployment_message: message, ...localApp, + ...preserveFields, }, }); } else { @@ -166,6 +191,7 @@ export async function pushApp( path: remotePath, deployment_message: message, ...localApp, + ...preserveFields, }, }); } @@ -302,6 +328,42 @@ const command = new Command() ); const { generateLocksCommand } = await import("./app_metadata.ts"); await generateLocksCommand(opts, appFolder); - }); + }) + .command( + "set-permissioned-as", + "Set the on_behalf_of_email for an app (requires admin or wm_deployers group)" + ) + .arguments(" ") + .action((async (opts: any, appPath: string, email: string) => { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const { lookupUsernameByEmail } = await import("../../core/permissioned_as.ts"); + const cache = new Map(); + const username = await lookupUsernameByEmail(workspace.workspaceId, email, cache); + + const remote = await wmill.getAppByPath({ + workspace: workspace.workspaceId, + path: appPath, + }); + if (!remote) throw new Error(`App ${appPath} not found`); + + // Only spread remote.policy — spreading the full remote object would include + // `value` and trigger a new app version on every call. EditApp has all-Option + // fields, so a minimal body only updates the policy column. + await wmill.updateApp({ + workspace: workspace.workspaceId, + path: appPath, + requestBody: { + policy: { + ...(remote.policy as any), + on_behalf_of: `u/${username}`, + on_behalf_of_email: email, + } as any, + preserve_on_behalf_of: true, + }, + }); + log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`)); + }) as any); export default command; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 41c270c7fb..a613aa2b0f 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -20,6 +20,7 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts"; import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts"; import { Flow } from "../../../gen/types.gen.ts"; +import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; import { collectPathScriptPaths, replaceInlineScripts, @@ -39,6 +40,8 @@ export interface FlowFile { description?: string; value: any; schema?: any; + on_behalf_of_email?: string; + has_on_behalf_of?: boolean; } function normalizeOptionalString(value: string | null | undefined): string | undefined { @@ -131,7 +134,8 @@ export async function pushFlow( workspace: string, remotePath: string, localPath: string, - message?: string + message?: string, + permissionedAsContext?: PermissionedAsContext ): Promise { if (alreadySynced.includes(localPath)) { return; @@ -177,6 +181,19 @@ export async function pushFlow( )); } + const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email; + delete (localFlow as any).has_on_behalf_of; + + const preserveFields: { on_behalf_of_email?: string; preserve_on_behalf_of?: boolean } = {}; + if (permissionedAsContext?.userIsAdminOrDeployer && hasOnBehalfOf) { + if (flow && flow.on_behalf_of_email) { + preserveFields.on_behalf_of_email = flow.on_behalf_of_email; + preserveFields.preserve_on_behalf_of = true; + log.info(`Preserving ${flow.on_behalf_of_email} as on_behalf_of for flow ${remotePath}`); + } + // On create: backend applies folder defaults — no client-side resolution needed + } + if (flow) { if (isSuperset(localFlow, flow)) { log.info(colors.green(`Flow ${remotePath} is up to date`)); @@ -190,6 +207,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlow, + ...preserveFields, }, }); } else { @@ -201,6 +219,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlow, + ...preserveFields, }, }); } catch (e) { @@ -867,6 +886,31 @@ const command = new Command() .command("show-version", "Show a specific version of a flow") .arguments(" ") .option("--json", "Output as JSON (for piping to jq)") - .action(showVersion as any); + .action(showVersion as any) + .command( + "set-permissioned-as", + "Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)" + ) + .arguments(" ") + .action((async (opts: any, flowPath: string, email: string) => { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const remote = await wmill.getFlowByPath({ + workspace: workspace.workspaceId, + path: flowPath, + }); + if (!remote) throw new Error(`Flow ${flowPath} not found`); + await wmill.updateFlow({ + workspace: workspace.workspaceId, + path: flowPath, + requestBody: { + ...remote, + path: flowPath, + on_behalf_of_email: email, + preserve_on_behalf_of: true, + } as any, + }); + log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`)); + }) as any); export default command; diff --git a/cli/src/commands/folder/folder.ts b/cli/src/commands/folder/folder.ts index bd298e6f88..23167353ef 100644 --- a/cli/src/commands/folder/folder.ts +++ b/cli/src/commands/folder/folder.ts @@ -19,6 +19,7 @@ export interface FolderFile { display_name: string | undefined; owners: Array | undefined; extra_perms: { [record: string]: boolean } | undefined; + default_permissioned_as?: Array<{ path_glob: string; permissioned_as: string }>; } async function list(opts: GlobalOptions & { json?: boolean }) { @@ -240,6 +241,76 @@ const command = new Command() "create default folder.meta.yaml for all subdirectories of f/ that are missing one" ) .option("-y, --yes", "skip confirmation prompt") - .action(addMissing as any); + .action(addMissing as any) + .command( + "show-rules", + "Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path." + ) + .arguments("") + .option("--test-path ", "Test which rule matches this item path (e.g. f/prod/jobs/my_script)") + .option("--json", "Output as JSON") + .action((async (opts: any, folderName: string) => { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const folder = await wmill.getFolder({ + workspace: workspace.workspaceId, + name: folderName, + }); + + const rules = (folder as any).default_permissioned_as ?? []; + + if (opts.json && !opts.testPath) { + console.log(JSON.stringify(rules, null, 2)); + return; + } + + if (rules.length === 0) { + log.info(`Folder '${folderName}' has no default_permissioned_as rules.`); + return; + } + + if (!opts.testPath) { + log.info(colors.bold(`Rules for folder '${folderName}' (first match wins):\n`)); + new Table() + .header(["#", "path_glob", "permissioned_as"]) + .padding(2) + .border(true) + .body(rules.map((r: any, i: number) => [String(i + 1), r.path_glob, r.permissioned_as])) + .render(); + return; + } + + // Test a path against the rules + const testPath = opts.testPath as string; + const prefix = `f/${folderName}/`; + if (!testPath.startsWith(prefix)) { + log.error(`Path '${testPath}' is not under folder '${folderName}' (expected prefix '${prefix}')`); + return; + } + const relative = testPath.slice(prefix.length); + + const { minimatch } = await import("minimatch"); + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (minimatch(relative, rule.path_glob)) { + if (opts.json) { + console.log(JSON.stringify({ matched: true, rule_index: i, rule, relative_path: relative })); + } else { + log.info(colors.green( + `✓ Rule #${i + 1} matches: path_glob='${rule.path_glob}' → permissioned_as='${rule.permissioned_as}'` + )); + log.info(colors.gray(` (relative path tested: '${relative}')`)); + } + return; + } + } + + if (opts.json) { + console.log(JSON.stringify({ matched: false, relative_path: relative })); + } else { + log.info(colors.yellow(`No rule matches path '${testPath}' (relative: '${relative}')`)); + } + }) as any); export default command; diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 046667b62c..210aae5aca 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -128,6 +128,8 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ commented: true }, { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app", inlineComment: "recommended for new projects" }, + { name: "syncBehavior", type: "string", default: "v1", description: "Sync behavior version — controls ownership handling during push/pull (v1: preserve permissioned_as on update, strip on_behalf_of_email on pull)", + inlineComment: "v1 enables ownership preservation" }, // ── Codebase bundling ───────────────────────────────────────────────── { name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries", diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index db57cabc64..37f8ac343d 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -11,6 +11,8 @@ import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; import { mergeConfigWithConfigFile } from "../../core/conf.ts"; import * as wmill from "../../../gen/services.gen.ts"; +import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; +import { lookupUsernameByEmail } from "../../core/permissioned_as.ts"; import { GlobalOptions, @@ -103,7 +105,8 @@ export async function pushSchedule( workspace: string, path: string, schedule: Schedule | ScheduleFile | undefined, - localSchedule: ScheduleFile + localSchedule: ScheduleFile, + permissionedAsContext?: PermissionedAsContext ): Promise { path = removeType(path, "schedule").replaceAll(SEP, "/"); log.debug(`Processing local schedule ${path}`); @@ -117,6 +120,21 @@ export async function pushSchedule( //ignore } + // Strip CLI-only boolean marker before sending to API + delete (localSchedule as any).has_permissioned_as; + + const preserveFields: { permissioned_as?: string; preserve_permissioned_as?: boolean } = {}; + if (permissionedAsContext?.userIsAdminOrDeployer) { + if (schedule) { + preserveFields.preserve_permissioned_as = true; + if ((schedule as Schedule).permissioned_as) { + preserveFields.permissioned_as = (schedule as Schedule).permissioned_as; + log.info(`Preserving ${(schedule as Schedule).permissioned_as} as permissioned_as for schedule ${path}`); + } + } + // On create: no client-side rule resolution needed — the backend applies folder defaults + } + if (schedule) { if (isSuperset(localSchedule, schedule)) { log.debug(`Schedule ${path} is up to date`); @@ -132,6 +150,7 @@ export async function pushSchedule( path, requestBody: { ...localSchedule, + ...preserveFields, }, }); if (localSchedule.enabled != schedule.enabled) { @@ -158,6 +177,7 @@ export async function pushSchedule( requestBody: { path: path, ...localSchedule, + ...preserveFields, }, }); } catch (e) { @@ -244,6 +264,43 @@ const command = new Command() .action(enable as any) .command("disable", "Disable a schedule") .arguments("") - .action(disable as any); + .action(disable as any) + .command( + "set-permissioned-as", + "Set the email (run-as user) for a schedule (requires admin or wm_deployers group)" + ) + .arguments(" ") + .action((async (opts: any, schedulePath: string, email: string) => { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const cache = new Map(); + const username = await lookupUsernameByEmail( + workspace.workspaceId, + email, + cache, + ); + + const remote = await wmill.getSchedule({ + workspace: workspace.workspaceId, + path: schedulePath, + }); + if (!remote) throw new Error(`Schedule ${schedulePath} not found`); + + await wmill.updateSchedule({ + workspace: workspace.workspaceId, + path: schedulePath, + requestBody: { + ...(remote as any), + permissioned_as: `u/${username}`, + preserve_permissioned_as: true, + } as any, + }); + log.info( + colors.green( + `Updated permissioned_as for schedule ${schedulePath} to ${email} (username: ${username})` + ) + ); + }) as any); export default command; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 6baceff758..0120988146 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -1,6 +1,7 @@ import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; +import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; import { readFile, writeFile, stat, mkdir } from "node:fs/promises"; import { Buffer } from "node:buffer"; import { colors } from "@cliffy/ansi/colors"; @@ -229,7 +230,8 @@ export async function handleScriptMetadata( message: string | undefined, rawWorkspaceDependencies: Record, codebases: SyncCodebase[], - opts: GlobalOptions + opts: GlobalOptions, + permissionedAsContext?: PermissionedAsContext ): Promise { // Flat layout: my_script.script.yaml const isFlatMeta = path.endsWith(".script.json") || @@ -250,7 +252,8 @@ export async function handleScriptMetadata( message, opts, rawWorkspaceDependencies, - codebases + codebases, + permissionedAsContext ); } else { return false; @@ -272,7 +275,8 @@ export async function handleFile( message: string | undefined, opts: (GlobalOptions & { defaultTs?: "bun" | "deno" } & Skips) | undefined, rawWorkspaceDependencies: Record, - codebases: SyncCodebase[] + codebases: SyncCodebase[], + permissionedAsContext?: PermissionedAsContext ): Promise { // Detect module entry point: e.g., my_script__mod/script.ts const moduleEntryPoint = isModuleEntryPoint(path); @@ -481,9 +485,18 @@ export async function handleFile( labels: typed?.labels, }; - // console.log(requestBodyCommon.codebase); - // log.info(JSON.stringify(requestBodyCommon, null, 2)) - // log.info(JSON.stringify(opts, null, 2)) + const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email; + delete (typed as any)?.has_on_behalf_of; + + if (permissionedAsContext?.userIsAdminOrDeployer && hasOnBehalfOf) { + if (remote && remote.on_behalf_of_email) { + requestBodyCommon.on_behalf_of_email = remote.on_behalf_of_email; + (requestBodyCommon as any).preserve_on_behalf_of = true; + log.info(`Preserving ${remote.on_behalf_of_email} as on_behalf_of for script ${remotePath}`); + } + // On create: backend applies folder defaults — no client-side resolution needed + } + if (remote) { if (content === remote.content) { if ( @@ -518,7 +531,7 @@ export async function handleFile( typed.debounce_key == remote["debounce_key"] && typed.debounce_delay_s == remote["debounce_delay_s"] && typed.codebase == remote.codebase && - typed.on_behalf_of_email == remote.on_behalf_of_email && + (hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) && deepEqual(typed.envs, remote.envs) && deepEqual(modules ?? null, remote.modules ?? null)) ) { @@ -1636,6 +1649,33 @@ async function history( } } +async function setPermissionedAs( + opts: GlobalOptions, + scriptPath: string, + email: string, +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const remote = await wmill.getScriptByPath({ + workspace: workspace.workspaceId, + path: scriptPath, + }); + if (!remote) throw new Error(`Script ${scriptPath} not found`); + + await wmill.createScript({ + workspace: workspace.workspaceId, + requestBody: { + ...(remote as any), + lock: Array.isArray(remote.lock) ? remote.lock.join("\n") : remote.lock ?? undefined, + parent_hash: remote.hash, + on_behalf_of_email: email, + preserve_on_behalf_of: true, + }, + }); + log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`)); +} + const command = new Command() .description("script related commands") .option("--show-archived", "Show archived scripts instead of active ones") @@ -1715,6 +1755,12 @@ const command = new Command() "Comma separated patterns to specify which file to NOT take into account." ) .action(generateMetadata as any) + .command( + "set-permissioned-as", + "Set the on_behalf_of_email for a script (requires admin or wm_deployers group)" + ) + .arguments(" ") + .action(setPermissionedAs as any) .command( "history", "show version history for a script" diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6e55d53e9a..a5dde194b8 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -46,11 +46,14 @@ import { import { getEffectiveSettings, mergeConfigWithConfigFile, + parseSyncBehavior, SyncOptions, validateBranchConfiguration, findWorkspaceByGitBranch, WorkspaceEntryConfig, } from "../../core/conf.ts"; +import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; +import { preCheckPermissionedAs } from "../../core/permissioned_as.ts"; import { fromWorkspaceSpecificPath, getWorkspaceSpecificPath, @@ -612,6 +615,7 @@ function ZipFSElement( resourceTypeToFormatExtension: Record, resourceTypeToIsFileset: Record, ignoreCodebaseChanges: boolean, + stripOnBehalfOf: boolean, ): DynFSElement { // Pre-scan: find zip base paths of scripts that have modules. // These scripts use the folder layout: {basePath}__mod/script.{ext} @@ -762,6 +766,11 @@ function ZipFSElement( }; } + if (stripOnBehalfOf) { + (flow as any).has_on_behalf_of = !!(flow as any).on_behalf_of_email; + delete (flow as any).on_behalf_of_email; + } + yield { isDirectory: false, path: path.join(finalPath, "flow.yaml"), @@ -1042,6 +1051,10 @@ function ZipFSElement( if (ignoreCodebaseChanges && parsed["codebase"]) { parsed["codebase"] = undefined; } + if (stripOnBehalfOf) { + parsed["has_on_behalf_of"] = !!parsed["on_behalf_of_email"]; + delete parsed["on_behalf_of_email"]; + } // Modules are stored as files in __mod/ folder, not in metadata delete parsed["modules"]; return useYaml @@ -1080,16 +1093,32 @@ function ZipFSElement( : JSON.stringify(parsed, null, 2); } - return useYaml && isJson && kind != "dependencies" - ? (() => { - try { - return yamlStringify(JSON.parse(content), yamlOptions); - } catch (error) { - log.error(`Failed to parse JSON content at path: ${p}`); - throw error; + if (isJson && kind != "dependencies") { + try { + const parsed = JSON.parse(content); + if (stripOnBehalfOf) { + const isSchedule = p.endsWith(".schedule.json"); + const isTrigger = p.endsWith("_trigger.json"); + if (isSchedule) { + parsed["has_permissioned_as"] = !!parsed["permissioned_as"]; + delete parsed["permissioned_as"]; + delete parsed["email"]; + delete parsed["edited_by"]; + } else if (isTrigger) { + parsed["has_permissioned_as"] = !!parsed["permissioned_as"]; + delete parsed["permissioned_as"]; + delete parsed["edited_by"]; } - })() - : content; + } + return useYaml + ? yamlStringify(parsed, yamlOptions) + : JSON.stringify(parsed, null, 2); + } catch (error) { + log.error(`Failed to parse JSON content at path: ${p}`); + throw error; + } + } + return content; }, }, ]; @@ -2210,6 +2239,7 @@ export async function pull( resourceTypeToFormatExtension, resourceTypeToIsFileset, true, + parseSyncBehavior(opts.syncBehavior) >= 1, ); const local = !opts.stateful @@ -2525,7 +2555,12 @@ export async function pull( } } -function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, branchOverride?: string) { +function prettyChanges( + changes: Change[], + specificItems?: SpecificItemsConfig, + branchOverride?: string, + folderDefaultAnnotations?: Map, +) { for (const change of changes) { let displayPath = change.path; let wsNote = ""; @@ -2543,13 +2578,18 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b } } + const folderNote = folderDefaultAnnotations?.get(change.path); + const extraNote = folderNote + ? colors.cyan(` (will be permissioned as ${folderNote} via folder default)`) + : ""; + if (change.name === "added") { log.info( colors.green( `+ ${getTypeStrFromPath(change.path)} ` + displayPath + colors.gray(wsNote), - ), + ) + extraNote, ); } else if (change.name === "deleted") { log.info( @@ -2619,7 +2659,7 @@ function removeSuffix(str: string, suffix: string) { } export async function push( - opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string }, + opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string; acceptOverridingPermissionedAsWithSelf?: boolean }, ) { if ((opts as any).jsonOutput) log.setSilent(true); // Save original CLI options before merging with config file @@ -2768,6 +2808,7 @@ export async function push( resourceTypeToFormatExtension, resourceTypeToIsFileset, false, + parseSyncBehavior(opts.syncBehavior) >= 1, ); const local = await FSFSElement(path.join(process.cwd(), ""), codebases, false); @@ -3028,14 +3069,72 @@ export async function push( } if (changes.length > 0) { + // Compute folder-default annotations for added items (shown in prettyChanges + dry-run) + let folderDefaultAnnotations: Map | undefined; + if (parseSyncBehavior(opts.syncBehavior) >= 1) { + folderDefaultAnnotations = new Map(); + const folderRulesCache = new Map>(); + for (const change of changes) { + if (change.name !== "added") continue; + const match = change.path.match(/^f\/([^/]+)\//); + if (!match) continue; + const folderName = match[1]; + if (!folderRulesCache.has(folderName)) { + try { + const folder = await wmill.getFolder({ workspace: workspace.workspaceId, name: folderName }); + folderRulesCache.set(folderName, (folder as any).default_permissioned_as ?? []); + } catch { + folderRulesCache.set(folderName, []); + } + } + const rules = folderRulesCache.get(folderName)!; + const remotePath = change.path.replace(/\.(script|schedule|http_trigger|websocket_trigger|kafka_trigger|nats_trigger|postgres_trigger|mqtt_trigger|sqs_trigger|gcp_trigger|email_trigger)\.(yaml|json)$/, "").replace(/(\.flow|__flow)\/flow\.(yaml|json)$/, "").replace(/\.(app|raw_app)(\/app\.(yaml|json))?$/, ""); + const relative = remotePath.slice(`f/${folderName}/`.length); + if (!relative) continue; + for (const rule of rules) { + if (minimatch(relative, rule.path_glob)) { + folderDefaultAnnotations.set(change.path, rule.permissioned_as); + break; + } + } + } + } + if (!opts.jsonOutput) { - prettyChanges(changes, specificItems, wsNameForFiles); + prettyChanges(changes, specificItems, wsNameForFiles, folderDefaultAnnotations); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); return; } + + let permissionedAsContext: PermissionedAsContext | undefined = undefined; + if (parseSyncBehavior(opts.syncBehavior) >= 1) { + const user = await wmill.whoami({ workspace: workspace.workspaceId }); + const userIsAdminOrDeployer = + user.is_admin || (user.groups ?? []).includes("wm_deployers"); + log.debug(`permissioned_as: user=${user.email}, is_admin=${user.is_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}`); + permissionedAsContext = { + userCache: new Map(), + userIsAdminOrDeployer, + userEmail: user.email, + }; + + await preCheckPermissionedAs( + changes, + user.email, + userIsAdminOrDeployer, + opts.acceptOverridingPermissionedAsWithSelf ?? false, + !!process.stdin.isTTY, + ); + } else if (folderDefaultAnnotations && folderDefaultAnnotations.size > 0) { + log.warn(colors.yellow( + `This workspace has folder default_permissioned_as rules that affect ${folderDefaultAnnotations.size} item(s) being pushed, ` + + `but syncBehavior is not set in wmill.yaml. Add 'syncBehavior: v1' to enable ownership preservation on update and on_behalf_of stripping on pull.` + )); + } + if ( !opts.yes && !(await Confirm.prompt({ @@ -3072,11 +3171,21 @@ export async function push( if (parallelizationFactor <= 0) { parallelizationFactor = 1; } - const groupedChangesArray = Array.from(groupedChanges.entries()); + // Partition changes: folder.meta.yaml changes must be applied BEFORE any + // item changes under those folders, so a push that updates a folder's + // default_permissioned_as rules AND creates new items under it in the same + // changeset has the rules in place when the backend resolves defaults for + // the new items. Folder changes run sequentially first; everything else + // runs through the parallel pool afterwards. + const allGrouped = Array.from(groupedChanges.entries()); + const isFolderMetaGroup = ([basePath]: [string, typeof changes]) => + basePath.endsWith(`${SEP}folder`) || basePath === "folder"; + const folderMetaGroups = allGrouped.filter(isFolderMetaGroup); + const groupedChangesArray = allGrouped.filter((g) => !isFolderMetaGroup(g)); log.info( `found changes for ${ - groupedChangesArray.length - } items with a total of ${groupedChangesArray.reduce( + allGrouped.length + } items with a total of ${allGrouped.reduce( (acc, [_, changes]) => acc + changes.length, 0, )} files to process`, @@ -3087,14 +3196,25 @@ export async function push( // Create a pool of workers that processes items as they become available const pool = new Set(); - const queue = [...groupedChangesArray]; + // Process folder.meta groups first (sequentially), then items in parallel. + // This ensures a newly-added default_permissioned_as rule is applied before + // any item created under that folder in the same push. + const queue = [...folderMetaGroups, ...groupedChangesArray]; + let folderPhaseRemaining = folderMetaGroups.length; + const effectiveParallelism = () => + folderPhaseRemaining > 0 ? 1 : parallelizationFactor; // Cache git branch at the start to avoid repeated execSync calls per change const cachedWsNameForPush = wsNameForFiles || (isGitRepository() ? getCurrentGitBranch() : null); while (queue.length > 0 || pool.size > 0) { - // Fill the pool until we reach parallelizationFactor - while (pool.size < parallelizationFactor && queue.length > 0) { - let [_basePath, changes] = queue.shift()!; + // Fill the pool until we reach the effective parallelism limit. + // During the folder-meta phase this is 1 (sequential) so no item change + // starts before all folder.meta updates have been applied to the backend. + while (pool.size < effectiveParallelism() && queue.length > 0) { + const [groupBasePath, initialChanges] = queue.shift()!; + let changes = initialChanges; + const isFolderGroup = + groupBasePath.endsWith(`${SEP}folder`) || groupBasePath === "folder"; const promise = (async () => { const alreadySynced: string[] = []; const deletedVarsResPaths: string[] = []; @@ -3133,6 +3253,7 @@ export async function push( rawWorkspaceDependencies, codebases, opts, + permissionedAsContext, ) ) { if (stateTarget) { @@ -3148,6 +3269,7 @@ export async function push( opts, rawWorkspaceDependencies, codebases, + permissionedAsContext, ) ) { if (stateTarget) { @@ -3267,6 +3389,7 @@ export async function push( alreadySynced, opts.message, originalWorkspaceSpecificPath, + permissionedAsContext, ); if (stateTarget) { @@ -3290,6 +3413,7 @@ export async function push( opts, rawWorkspaceDependencies, codebases, + permissionedAsContext, ) ) { continue; @@ -3336,6 +3460,7 @@ export async function push( [], opts.message, localFilePath, // Pass the actual local file path + permissionedAsContext, ); if (stateTarget) { @@ -3690,7 +3815,10 @@ export async function push( pool.add(promise); // Remove from pool when complete - promise.then(() => pool.delete(promise)); + promise.then(() => { + pool.delete(promise); + if (isFolderGroup) folderPhaseRemaining--; + }); } // Wait for at least one task to complete before continuing @@ -3872,6 +4000,10 @@ const command = new Command() "Fail if scripts or flow inline scripts that need locks have no locks", ) .option("--auto-metadata", "Automatically regenerate stale metadata (locks and schemas) before pushing") + .option( + "--accept-overriding-permissioned-as-with-self", + "Accept that items with a different permissioned_as will be updated with your own user", + ) .action(push as any); export default command; diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 79132f7842..df2657427c 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -37,6 +37,7 @@ import { import { getCurrentGitBranch } from "../../utils/git.ts"; import { requireLogin } from "../../core/auth.ts"; import { validatePath, resolveWorkspace } from "../../core/context.ts"; +import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; type Trigger = { http: HttpTrigger; @@ -149,7 +150,8 @@ export async function pushTrigger( workspace: string, path: string, trigger: TriggerFile | Trigger[K] | undefined, - localTrigger: TriggerFile + localTrigger: TriggerFile, + permissionedAsContext?: PermissionedAsContext ): Promise { path = removeType(path, triggerType + "_trigger").replaceAll(SEP, "/"); log.debug(`Processing local ${triggerType} trigger ${path}`); @@ -162,6 +164,20 @@ export async function pushTrigger( //ignore } + // Strip CLI-only boolean marker before sending to API + delete (localTrigger as any).has_permissioned_as; + + const preserveFields: { permissioned_as?: string; preserve_permissioned_as?: boolean } = {}; + if (permissionedAsContext?.userIsAdminOrDeployer) { + if (trigger) { + preserveFields.preserve_permissioned_as = true; + if ((trigger as any).permissioned_as) { + preserveFields.permissioned_as = (trigger as any).permissioned_as; + log.info(`Preserving ${(trigger as any).permissioned_as} as permissioned_as for trigger ${path}`); + } + } + } + if (trigger) { if (isSuperset(localTrigger, trigger)) { log.debug(`${triggerType} trigger ${path} is up to date`); @@ -171,6 +187,7 @@ export async function pushTrigger( try { await updateTrigger(triggerType, workspace, path, { ...localTrigger, + ...preserveFields, path, } as Trigger[K]); } catch (e) { @@ -184,6 +201,7 @@ export async function pushTrigger( try { await createTrigger(triggerType, workspace, path, { ...localTrigger, + ...preserveFields, path, } as Trigger[K]); } catch (e) { @@ -615,6 +633,43 @@ const command = new Command() "push a local trigger spec. This overrides any remote versions." ) .arguments(" ") - .action(push as any); + .action(push as any) + .command( + "set-permissioned-as", + "Set the email (run-as user) for a trigger (requires admin or wm_deployers group)" + ) + .arguments(" ") + .option( + "--kind ", + "Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)" + ) + .action((async (opts: any, triggerPath: string, email: string) => { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + if (!opts.kind) { + throw new Error("--kind is required. Valid kinds: " + TRIGGER_TYPES.join(", ")); + } + if (!checkIfValidTrigger(opts.kind)) { + throw new Error("Invalid trigger kind: " + opts.kind + ". Valid kinds: " + TRIGGER_TYPES.join(", ")); + } + + const { lookupUsernameByEmail } = await import("../../core/permissioned_as.ts"); + const cache = new Map(); + const username = await lookupUsernameByEmail(workspace.workspaceId, email, cache); + + const remote = await getTrigger(opts.kind as TriggerType, workspace.workspaceId, triggerPath); + if (!remote) throw new Error(`${opts.kind} trigger ${triggerPath} not found`); + + await updateTrigger(opts.kind, workspace.workspaceId, triggerPath, { + ...(remote as any), + permissioned_as: `u/${username}`, + preserve_permissioned_as: true, + path: triggerPath, + } as any); + log.info(colors.green( + `Updated permissioned_as for ${opts.kind} trigger ${triggerPath} to ${email} (username: ${username})` + )); + }) as any); export default command; diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index bebef5cb00..5656cfe9d4 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -55,6 +55,15 @@ type LegacyBranchesConfig = { }; }; +export const SUPPORTED_SYNC_BEHAVIOR_VERSION = 1; + +export function parseSyncBehavior(value?: string | number): number { + if (!value && value !== 0) return 0; + const s = String(value); + const match = s.match(/^v(\d+)$/); + return match ? parseInt(match[1], 10) : 0; +} + export interface SyncOptions { stateful?: boolean; raw?: boolean; @@ -98,6 +107,7 @@ export interface SyncOptions { promotion?: string; lint?: boolean; locksRequired?: boolean; + syncBehavior?: string; } export interface Codebase { @@ -274,6 +284,14 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis // Initialize global nonDottedPaths setting from config setNonDottedPaths(conf?.nonDottedPaths ?? false); + const syncBehaviorVersion = parseSyncBehavior(conf?.syncBehavior); + if (syncBehaviorVersion > SUPPORTED_SYNC_BEHAVIOR_VERSION) { + log.error( + `Your wmill.yaml specifies syncBehavior: ${conf!.syncBehavior}, but this CLI only supports up to v${SUPPORTED_SYNC_BEHAVIOR_VERSION}. Run 'wmill upgrade' to update.` + ); + process.exit(1); + } + return typeof conf == "object" ? conf : ({} as SyncOptions); } catch (e) { if ( @@ -333,6 +351,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly< | "includeSettings" | "includeKey" | "nonDottedPaths" + | "syncBehavior" > > > = { @@ -356,6 +375,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly< includeKey: false, skipWorkspaceDependencies: false, nonDottedPaths: false, + syncBehavior: "v1", } as const; export async function mergeConfigWithConfigFile( diff --git a/cli/src/core/permissioned_as.ts b/cli/src/core/permissioned_as.ts new file mode 100644 index 0000000000..5ac48753d6 --- /dev/null +++ b/cli/src/core/permissioned_as.ts @@ -0,0 +1,210 @@ +import * as wmill from "../../gen/services.gen.ts"; +import * as log from "./log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { getTypeStrFromPath } from "../types.ts"; + +export interface PermissionedAsContext { + userCache: Map; + userIsAdminOrDeployer: boolean; + userEmail: string; +} + +async function ensureUserCache( + workspace: string, + cache: Map +): Promise { + if (cache.size > 0) return; + const users = await wmill.listUsers({ workspace }); + for (const user of users) { + cache.set(user.username, { username: user.username, email: user.email }); + cache.set(user.email, { username: user.username, email: user.email }); + } +} + +export async function lookupUsernameByEmail( + workspace: string, + email: string, + cache: Map +): Promise { + await ensureUserCache(workspace, cache); + const entry = cache.get(email); + if (!entry) { + throw new Error( + `Could not find username for email '${email}' in workspace. ` + + `Make sure the user exists in the workspace.` + ); + } + return entry.username; +} + +export interface Change { + name: "edited" | "added" | "deleted"; + path: string; + before?: string; + after?: string; + content?: string; +} + +function contentHasOnBehalfOf(content: string, typeStr: string): boolean { + if (typeStr === "script") { + return ( + !!content.match(/has_on_behalf_of:\s*(true)/) || + !!content.match(/on_behalf_of_email:\s*["']?([^\s"']+)["']?/) + ); + } + if (typeStr === "flow") { + return !!content.match(/has_on_behalf_of:\s*(true)/); + } + return false; +} + +export async function preCheckPermissionedAs( + changes: Change[], + userEmail: string, + userIsAdminOrDeployer: boolean, + acceptOverride: boolean, + isInteractive: boolean +): Promise { + // Admins and wm_deployers can always preserve ownership via the CLI's + // preserve-on-update path, so nothing would silently change for them. + if (userIsAdminOrDeployer) return; + + const wouldChangeItems: { path: string; currentOwner: string }[] = []; + + for (const change of changes) { + let typeStr: string; + try { + typeStr = getTypeStrFromPath(change.path); + } catch { + continue; + } + + if (change.name === "added") { + const content = change.content; + if (!content) continue; + + const isScriptMeta = + typeStr === "script" && + (change.path.endsWith(".script.yaml") || + change.path.endsWith(".script.json")); + const isFlowMeta = + typeStr === "flow" && + (change.path.endsWith("flow.yaml") || + change.path.endsWith("flow.json")); + + if ( + (isScriptMeta || isFlowMeta) && + contentHasOnBehalfOf(content, typeStr) + ) { + const label = + typeStr === "script" ? "(script owner)" : "(flow owner)"; + wouldChangeItems.push({ path: change.path, currentOwner: label }); + } else if (typeStr === "app") { + wouldChangeItems.push({ + path: change.path, + currentOwner: "(app policy owner)", + }); + } + continue; + } + + if (change.name !== "edited") continue; + + const beforeContent = change.before; + if (!beforeContent) continue; + + let currentOwner: string | undefined; + + if (typeStr === "script") { + if ( + change.path.endsWith(".script.yaml") || + change.path.endsWith(".script.json") + ) { + const hasOboMatch = beforeContent.match(/has_on_behalf_of:\s*(true)/); + if (hasOboMatch) { + currentOwner = "(script owner)"; + } else { + const emailMatch = beforeContent.match( + /on_behalf_of_email:\s*["']?([^\s"']+)["']?/ + ); + if (emailMatch) { + currentOwner = emailMatch[1]; + } + } + } + } else if (typeStr === "flow") { + if ( + change.path.endsWith("flow.yaml") || + change.path.endsWith("flow.json") + ) { + const hasOboMatch = beforeContent.match(/has_on_behalf_of:\s*(true)/); + if (hasOboMatch) { + wouldChangeItems.push({ + path: change.path, + currentOwner: "(flow owner)", + }); + } + } + continue; + } else if (typeStr === "app") { + wouldChangeItems.push({ + path: change.path, + currentOwner: "(app policy owner)", + }); + continue; + } else if (typeStr === "schedule") { + const match = beforeContent.match( + /email:\s*["']?([^\s"']+)["']?/ + ); + if (match) { + currentOwner = match[1]; + } + } else if (typeStr.endsWith("_trigger")) { + wouldChangeItems.push({ + path: change.path, + currentOwner: "(trigger owner)", + }); + continue; + } + + if (currentOwner && currentOwner !== userEmail) { + wouldChangeItems.push({ path: change.path, currentOwner }); + } + } + + if (wouldChangeItems.length === 0) return; + + const itemList = wouldChangeItems + .map((item) => ` - ${item.path} (current owner: ${item.currentOwner})`) + .join("\n"); + + const message = + `You are not an admin or member of 'wm_deployers'. The following ${wouldChangeItems.length} item(s) ` + + `will have their permissioned_as/email changed to your user (${userEmail}):\n${itemList}`; + + if (acceptOverride) { + log.warn(colors.yellow(`Warning: ${message}`)); + return; + } + + if (isInteractive) { + log.warn(colors.yellow(message)); + const proceed = await Confirm.prompt({ + message: + "Do you want to proceed? (use --accept-overriding-permissioned-as-with-self to skip this prompt)", + default: false, + }); + if (!proceed) { + log.info("Push cancelled."); + process.exit(0); + } + } else { + log.error( + colors.red( + `${message}\n\nUse --accept-overriding-permissioned-as-with-self to proceed anyway.` + ) + ); + process.exit(1); + } +} diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 5eb785faa0..c405016a1f 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5109,6 +5109,7 @@ app related commands - \`--fix\` - Attempt to fix common issues (not implemented yet) - \`app new\` - create a new raw app from a template - \`app generate-agents [app_folder:string]\` - regenerate AGENTS.md and DATATABLES.md from remote workspace +- \`app set-permissioned-as \` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group) ### audit @@ -5191,6 +5192,7 @@ flow related commands - \`--json\` - Output as JSON (for piping to jq) - \`flow show-version \` - Show a specific version of a flow - \`--json\` - Output as JSON (for piping to jq) +- \`flow set-permissioned-as \` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group) ### folder @@ -5210,6 +5212,9 @@ folder related commands - \`folder push \` - push a local folder to the remote by name. This overrides any remote versions. - \`folder add-missing\` - create default folder.meta.yaml for all subdirectories of f/ that are missing one - \`-y, --yes\` - skip confirmation prompt +- \`folder show-rules \` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path. + - \`--test-path \` - Test which rule matches this item path (e.g. f/prod/jobs/my_script) + - \`--json\` - Output as JSON ### generate-metadata @@ -5433,6 +5438,7 @@ schedule related commands - \`schedule push \` - push a local schedule spec. This overrides any remote versions. - \`schedule enable \` - Enable a schedule - \`schedule disable \` - Disable a schedule +- \`schedule set-permissioned-as \` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group) ### script @@ -5464,6 +5470,7 @@ script related commands - \`script bootstrap \` - create a new script (alias for new) - \`--summary \` - script summary - \`--description \` - script description +- \`script set-permissioned-as \` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group) - \`script history \` - show version history for a script - \`--json\` - Output as JSON (for piping to jq) @@ -5537,6 +5544,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing + - \`--accept-overriding-permissioned-as-with-self\` - Accept that items with a different permissioned_as will be updated with your own user ### token @@ -5571,6 +5579,8 @@ trigger related commands - \`trigger new \` - create a new trigger locally - \`--kind \` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email) - \`trigger push \` - push a local trigger spec. This overrides any remote versions. +- \`trigger set-permissioned-as \` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group) + - \`--kind \` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email) ### user diff --git a/cli/src/types.ts b/cli/src/types.ts index bfa0a47b9c..a2bc8bea69 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -22,6 +22,7 @@ import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies. import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts"; import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts"; import { pushRawApp } from "./commands/app/raw_apps.ts"; +import type { PermissionedAsContext } from "./core/permissioned_as.ts"; import { isFlowPath, isAppPath, @@ -152,7 +153,8 @@ export async function pushObj( plainSecrets: boolean, alreadySynced: string[], message?: string, - originalLocalPath?: string + originalLocalPath?: string, + permissionedAsContext?: PermissionedAsContext ) { const typeEnding = getTypeStrFromPath(p); @@ -161,7 +163,7 @@ export async function pushObj( if (!appName) { throw new Error(`Could not extract app name from path: ${p}`); } - await pushApp(workspace, appName, buildFolderPath(appName, "app"), message); + await pushApp(workspace, appName, buildFolderPath(appName, "app"), message, permissionedAsContext); } else if (typeEnding === "raw_app") { const rawAppName = extractResourceName(p, "raw_app"); if (!rawAppName) { @@ -177,7 +179,7 @@ export async function pushObj( if (!flowName) { throw new Error(`Could not extract flow name from path: ${p}`); } - await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message); + await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message, permissionedAsContext); } else if (typeEnding === "resource") { if (!alreadySynced.includes(p)) { alreadySynced.push(p); @@ -186,25 +188,25 @@ export async function pushObj( } else if (typeEnding === "resource-type") { await pushResourceType(workspace, p, befObj, newObj); } else if (typeEnding === "schedule") { - await pushSchedule(workspace, p, befObj, newObj); + await pushSchedule(workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "http_trigger") { - await pushTrigger("http", workspace, p, befObj, newObj); + await pushTrigger("http", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "websocket_trigger") { - await pushTrigger("websocket", workspace, p, befObj, newObj); + await pushTrigger("websocket", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "kafka_trigger") { - await pushTrigger("kafka", workspace, p, befObj, newObj); + await pushTrigger("kafka", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "nats_trigger") { - await pushTrigger("nats", workspace, p, befObj, newObj); + await pushTrigger("nats", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "postgres_trigger") { - await pushTrigger("postgres", workspace, p, befObj, newObj); + await pushTrigger("postgres", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "mqtt_trigger") { - await pushTrigger("mqtt", workspace, p, befObj, newObj); + await pushTrigger("mqtt", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "sqs_trigger") { - await pushTrigger("sqs", workspace, p, befObj, newObj); + await pushTrigger("sqs", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "gcp_trigger") { - await pushTrigger("gcp", workspace, p, befObj, newObj); + await pushTrigger("gcp", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "email_trigger") { - await pushTrigger("email", workspace, p, befObj, newObj); + await pushTrigger("email", workspace, p, befObj, newObj, permissionedAsContext); } else if (typeEnding === "native_trigger") { await pushNativeTrigger(workspace, p, befObj, newObj); } else if (typeEnding === "user") { diff --git a/cli/test/folder_default_permissioned_as.test.ts b/cli/test/folder_default_permissioned_as.test.ts new file mode 100644 index 0000000000..5af16d3355 --- /dev/null +++ b/cli/test/folder_default_permissioned_as.test.ts @@ -0,0 +1,636 @@ +/** + * Integration tests for folder default_permissioned_as — full lifecycle: + * 1. Full lifecycle: create folder via CLI, set rules, push, pull, modify, push, + * create new item under folder — verifies rules are applied at each step + * 2. Fork/merge: parent workspace has folder with rules, fork inherits them, + * fork can update rules, deployed back to parent + * 3. Backend validation + resolution (minimal API-level checks) + * 4. Stale rule rejection + */ + +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend, type TestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +async function setupWorkspaceProfile(backend: any): Promise { + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "localhost_test", + token: backend.token, + }, + { force: true, configDir: backend.testConfigDir } + ); +} + +async function api( + backend: TestBackend, + path: string, + options: RequestInit = {} +): Promise { + return backend.apiRequest!(path, options); +} + +async function createFolderWithRules( + backend: any, + name: string, + rules: Array<{ path_glob: string; permissioned_as: string }>, + extraPerms?: Record, + workspaceId?: string +): Promise { + const ws = workspaceId ?? backend.workspace; + const resp = await backend.apiRequest!(`/api/w/${ws}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + default_permissioned_as: rules, + extra_perms: extraPerms ?? {}, + }), + }); + await resp.text(); + expect(resp.status).toBeLessThan(300); +} + +async function updateFolderRules( + backend: any, + name: string, + rules: Array<{ path_glob: string; permissioned_as: string }>, + workspaceId?: string +): Promise { + const ws = workspaceId ?? backend.workspace; + return backend.apiRequest!(`/api/w/${ws}/folders/update/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ default_permissioned_as: rules }), + }); +} + +async function createScript( + backend: any, + path: string, + workspaceId?: string +): Promise { + const ws = workspaceId ?? backend.workspace; + const resp = await backend.apiRequest!(`/api/w/${ws}/scripts/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + summary: "helper", + description: "", + content: "export async function main() { return 42; }", + language: "deno", + schema: { type: "object", properties: {}, required: [] }, + }), + }); + const body = await resp.text(); + expect(resp.status).toBe(201); + return body; +} + +async function createSchedule( + backend: any, + path: string, + scriptPath: string, + extra?: Record, + workspaceId?: string +): Promise { + const ws = workspaceId ?? backend.workspace; + return backend.apiRequest!(`/api/w/${ws}/schedules/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + schedule: "0 0 */6 * * *", + timezone: "UTC", + script_path: scriptPath, + is_flow: false, + enabled: false, + ...extra, + }), + }); +} + +async function getSchedule( + backend: any, + path: string, + workspaceId?: string +): Promise { + const ws = workspaceId ?? backend.workspace; + const resp = await backend.apiRequest!(`/api/w/${ws}/schedules/get/${path}`); + expect(resp.status).toBe(200); + return resp.json(); +} + +async function getFolder( + backend: any, + name: string, + workspaceId?: string +): Promise { + const ws = workspaceId ?? backend.workspace; + const resp = await backend.apiRequest!(`/api/w/${ws}/folders/get/${name}`); + expect(resp.status).toBe(200); + return resp.json(); +} + +async function runSQL(query: string): Promise { + const dbUrl = + process.env["DATABASE_URL"] || + "postgres://postgres:changeme@localhost:5432"; + const proc = Bun.spawn( + [ + "psql", + `${dbUrl}/postgres?sslmode=disable`, + "-t", + "-c", + `SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`, + ], + { stdout: "pipe", stderr: "pipe" } + ); + const dbName = (await new Response(proc.stdout).text()).trim(); + await proc.exited; + if (!dbName) throw new Error("Could not find test database"); + const sqlProc = Bun.spawn( + ["psql", `${dbUrl}/${dbName}?sslmode=disable`, "-c", query], + { stdout: "pipe", stderr: "pipe" } + ); + await sqlProc.exited; +} + +async function removeFromSkipTally(workspaceId: string) { + await runSQL( + `DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${workspaceId}'` + ); +} + +async function deleteFork(backend: TestBackend, forkId: string) { + try { + await api(backend, `/api/w/${forkId}/workspaces/delete`, { method: "POST" }); + } catch {} + const esc = forkId.replace(/'/g, "''"); + await runSQL(` + SET session_replication_role = replica; + DO $$ DECLARE r RECORD; BEGIN + FOR r IN SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.column_name = 'workspace_id' AND c.table_schema = 'public' AND t.table_type = 'BASE TABLE' + GROUP BY c.table_name + LOOP EXECUTE format('DELETE FROM %I WHERE workspace_id = ''${esc}''', r.table_name); + END LOOP; + END $$; + DELETE FROM workspace WHERE id = '${esc}'; + DELETE FROM workspace_diff WHERE fork_workspace_id = '${esc}'; + DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${esc}'; + SET session_replication_role = DEFAULT; + `); +} + +describe("folder default_permissioned_as", () => { + // ========================================================================== + // Test 1: Full CLI lifecycle — create, push, pull, modify, push, verify apply + // ========================================================================== + test("full CLI lifecycle: push rules → pull back → modify → push → apply on new item", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const id = Date.now(); + const folderName = `dpa_life_${id}`; + + // Step 1: Seed an empty folder + helper script via API (needed so schedules can reference it) + await createFolderWithRules(backend, folderName, []); + await createScript(backend, `f/${folderName}/helper`); + + // Step 2: Pull workspace locally + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nsyncBehavior: v1\nincludes:\n - "f/${folderName}/**"\nincludeSchedules: true\n`, + "utf-8" + ); + let r = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(r.code).toEqual(0); + + // Step 3: Locally edit folder.meta.yaml to add default_permissioned_as rules + const metaPath = join(tempDir, "f", folderName, "folder.meta.yaml"); + const metaContent = await readFile(metaPath, "utf-8"); + // Backend always emits the field; initially it's an empty array + expect(metaContent).toContain("default_permissioned_as: []"); + + const newMeta = `display_name: ${folderName} +owners: + - u/admin +extra_perms: + u/admin: true +summary: null +default_permissioned_as: + - path_glob: "jobs/critical/**" + permissioned_as: "g/all" + - path_glob: "jobs/**" + permissioned_as: "u/admin" +`; + await writeFile(metaPath, newMeta, "utf-8"); + + // Step 4: Push the folder update AND a brand-new schedule at the same time. + // This tests the folder-first ordering: the new schedule should pick up + // the new folder rule within the same push batch. + const schedPath = join( + tempDir, + "f", + folderName, + "new_sched.schedule.yaml" + ); + await writeFile( + schedPath, + `schedule: "0 0 */6 * * *" +timezone: UTC +script_path: f/${folderName}/helper +is_flow: false +enabled: false +`, + "utf-8" + ); + + r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(r.code).toEqual(0); + + // Verify: folder has the new rules + const folder = await getFolder(backend, folderName); + expect(folder.default_permissioned_as).toHaveLength(2); + expect(folder.default_permissioned_as[0].path_glob).toBe( + "jobs/critical/**" + ); + expect(folder.default_permissioned_as[1].path_glob).toBe("jobs/**"); + + // Verify: schedule got u/admin (the jobs/** rule) — only works if folder + // was pushed FIRST within the same batch + const sched = await getSchedule(backend, `f/${folderName}/new_sched`); + expect(sched.permissioned_as).toBe("u/admin"); + + // Step 5: Pull again — verify the updated rules round-trip through YAML + r = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(r.code).toEqual(0); + const pulledMeta = await readFile(metaPath, "utf-8"); + expect(pulledMeta).toContain("default_permissioned_as:"); + expect(pulledMeta).toContain("jobs/critical/**"); + expect(pulledMeta).toContain("g/all"); + + // Step 6: Reorder rules locally and push again — verify order changes + const reorderedMeta = `display_name: ${folderName} +owners: + - u/admin +extra_perms: + u/admin: true +summary: null +default_permissioned_as: + - path_glob: "jobs/**" + permissioned_as: "u/admin" + - path_glob: "jobs/critical/**" + permissioned_as: "g/all" +`; + await writeFile(metaPath, reorderedMeta, "utf-8"); + r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(r.code).toEqual(0); + + const folder2 = await getFolder(backend, folderName); + expect(folder2.default_permissioned_as[0].path_glob).toBe("jobs/**"); + expect(folder2.default_permissioned_as[1].path_glob).toBe( + "jobs/critical/**" + ); + + // Step 7: Now that jobs/** is listed first, a new critical schedule should + // ALSO resolve to u/admin (first-match-wins, critical/** is shadowed) + const schedPath2 = join( + tempDir, + "f", + folderName, + "crit_sched.schedule.yaml" + ); + await mkdir(join(tempDir, "f", folderName, "jobs", "critical"), { + recursive: true, + }); + const critYaml = `schedule: "0 0 */6 * * *" +timezone: UTC +script_path: f/${folderName}/helper +is_flow: false +enabled: false +`; + await writeFile( + join( + tempDir, + "f", + folderName, + "jobs", + "critical", + "crit_sched.schedule.yaml" + ), + critYaml, + "utf-8" + ); + r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(r.code).toEqual(0); + + const critSched = await getSchedule( + backend, + `f/${folderName}/jobs/critical/crit_sched` + ); + expect(critSched.permissioned_as).toBe("u/admin"); // shadowed by jobs/** + + // Step 8: Clear rules locally and push — verify rules are cleared on backend + const clearedMeta = `display_name: ${folderName} +owners: + - u/admin +extra_perms: + u/admin: true +summary: null +default_permissioned_as: [] +`; + await writeFile(metaPath, clearedMeta, "utf-8"); + r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(r.code).toEqual(0); + + const folder3 = await getFolder(backend, folderName); + expect(folder3.default_permissioned_as).toHaveLength(0); + + // Step 9: A new schedule after clearing falls back to the acting user + await writeFile( + join(tempDir, "f", folderName, "after_clear.schedule.yaml"), + critYaml, + "utf-8" + ); + r = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(r.code).toEqual(0); + + const sched3 = await getSchedule( + backend, + `f/${folderName}/after_clear` + ); + expect(sched3.permissioned_as).toBe("u/admin"); // admin is the acting user + }); + }); + + // ========================================================================== + // Test 2: Fork/merge with default_permissioned_as + // ========================================================================== + test("fork/merge: rules propagate to fork and deploy back to parent", async () => { + await withTestBackend(async (backend, _tempDir) => { + const parentWs = backend.workspace; + const FORK_ID = "wm-fork-dpa"; + await deleteFork(backend, FORK_ID); + + const id = Date.now(); + const folderName = `dpa_fork_${id}`; + + // Step 1: Parent workspace: create folder with rules + helper script + await createFolderWithRules(backend, folderName, [ + { path_glob: "jobs/**", permissioned_as: "u/admin" }, + ]); + await createScript(backend, `f/${folderName}/helper`); + + // Step 2: Create a fork — fork inherits the parent's folder including rules + const forkResp = await api( + backend, + `/api/w/${parentWs}/workspaces/create_fork`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: FORK_ID, + name: "DPA Fork", + color: "#ff5500", + forked_datatables: [], + }), + } + ); + if (!forkResp.ok) { + const err = await forkResp.text(); + throw new Error(`Fork creation failed: ${forkResp.status} ${err}`); + } + await removeFromSkipTally(FORK_ID); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Step 3: Verify fork inherited the folder rules + const forkFolder = await getFolder(backend, folderName, FORK_ID); + expect(forkFolder.default_permissioned_as).toHaveLength(1); + expect(forkFolder.default_permissioned_as[0].path_glob).toBe("jobs/**"); + expect(forkFolder.default_permissioned_as[0].permissioned_as).toBe( + "u/admin" + ); + + // Step 4: Create a schedule in the fork at a matching path — rule applies + const forkSchedResp = await createSchedule( + backend, + `f/${folderName}/jobs/fork_sched`, + `f/${folderName}/helper`, + undefined, + FORK_ID + ); + expect(forkSchedResp.status).toBe(200); + await forkSchedResp.text(); + + const forkSched = await getSchedule( + backend, + `f/${folderName}/jobs/fork_sched`, + FORK_ID + ); + expect(forkSched.permissioned_as).toBe("u/admin"); + + // Step 5: Modify the fork's rules (simulating divergence) + const updateResp = await updateFolderRules( + backend, + folderName, + [ + { path_glob: "jobs/**", permissioned_as: "u/admin" }, + { path_glob: "reports/**", permissioned_as: "g/all" }, + ], + FORK_ID + ); + expect(updateResp.status).toBe(200); + await updateResp.text(); + + // Step 6: Deploy the fork's folder rules back to parent via API + const forkFolderUpdated = await getFolder(backend, folderName, FORK_ID); + const deployResp = await updateFolderRules( + backend, + folderName, + forkFolderUpdated.default_permissioned_as, + parentWs + ); + expect(deployResp.status).toBe(200); + await deployResp.text(); + + // Step 7: Verify parent now has the updated rules + const parentFolderAfter = await getFolder(backend, folderName, parentWs); + expect(parentFolderAfter.default_permissioned_as).toHaveLength(2); + expect(parentFolderAfter.default_permissioned_as[1].path_glob).toBe( + "reports/**" + ); + expect(parentFolderAfter.default_permissioned_as[1].permissioned_as).toBe( + "g/all" + ); + + // Step 8: A new schedule in the parent at a reports/ path should now get + // the newly-merged g/all rule + const parentSchedResp = await createSchedule( + backend, + `f/${folderName}/reports/parent_sched`, + `f/${folderName}/helper`, + undefined, + parentWs + ); + expect(parentSchedResp.status).toBe(200); + await parentSchedResp.text(); + + const parentSched = await getSchedule( + backend, + `f/${folderName}/reports/parent_sched`, + parentWs + ); + expect(parentSched.permissioned_as).toBe("g/all"); + + await deleteFork(backend, FORK_ID); + }); + }); + + // ========================================================================== + // Test 3: No-op guarantee — feature is inert when rules are not used + // ========================================================================== + test("no-op: folder without rules behaves like before for all deploy paths", async () => { + await withTestBackend(async (backend, _tempDir) => { + const id = Date.now(); + const folderName = `dpa_noop_${id}`; + + // Create folder with NO rules + await createFolderWithRules(backend, folderName, []); + await createScript(backend, `f/${folderName}/helper`); + + // Schedule create: should use acting user (admin), not fail or behave differently + const schedResp = await createSchedule( + backend, + `f/${folderName}/noop_sched`, + `f/${folderName}/helper` + ); + expect(schedResp.status).toBe(200); + await schedResp.text(); + const sched = await getSchedule(backend, `f/${folderName}/noop_sched`); + expect(sched.permissioned_as).toBe("u/admin"); + expect(sched.email).toBe("admin@windmill.dev"); + + // Schedule at a nested path: still acting user + const schedResp2 = await createSchedule( + backend, + `f/${folderName}/jobs/deep/sched`, + `f/${folderName}/helper` + ); + expect(schedResp2.status).toBe(200); + await schedResp2.text(); + const sched2 = await getSchedule( + backend, + `f/${folderName}/jobs/deep/sched` + ); + expect(sched2.permissioned_as).toBe("u/admin"); + + // Flow create: on_behalf_of_email stays null (nothing injected) + const flowResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/flows/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/${folderName}/noop_flow`, + summary: "", + description: "", + value: { modules: [] }, + schema: { type: "object", properties: {}, required: [] }, + }), + } + ); + expect(flowResp.status).toBe(201); + await flowResp.text(); + const flow = await ( + await backend.apiRequest!( + `/api/w/${backend.workspace}/flows/get/f/${folderName}/noop_flow` + ) + ).json(); + // no folder rule, no client-sent on_behalf_of_email → should be null/undefined + expect(flow.on_behalf_of_email == null).toBe(true); + + // Folder response has the field but it's empty + const folder = await getFolder(backend, folderName); + expect(folder.default_permissioned_as).toEqual([]); + + // Folder update without touching default_permissioned_as should leave it alone + const updateResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/update/${folderName}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ summary: "updated summary" }), + } + ); + expect(updateResp.status).toBe(200); + await updateResp.text(); + const folder2 = await getFolder(backend, folderName); + expect(folder2.summary).toBe("updated summary"); + expect(folder2.default_permissioned_as).toEqual([]); + }); + }); + + // ========================================================================== + // Test 4: Backend validation + stale rule rejection (minimal API-level) + // ========================================================================== + test("validation and stale rule rejection", async () => { + await withTestBackend(async (backend, _tempDir) => { + const id = Date.now(); + const folderName = `dpa_val_${id}`; + await createFolderWithRules(backend, folderName, []); + + // Invalid glob + const r1 = await updateFolderRules(backend, folderName, [ + { path_glob: "[unclosed", permissioned_as: "u/admin" }, + ]); + expect(r1.status).toBe(400); + const b1 = await r1.text(); + expect(b1).toContain("not a valid glob"); + + // Invalid permissioned_as format + const r2 = await updateFolderRules(backend, folderName, [ + { path_glob: "**", permissioned_as: "bogus" }, + ]); + expect(r2.status).toBe(400); + await r2.text(); + + // Non-array + const r3 = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/update/${folderName}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ default_permissioned_as: "not-an-array" }), + } + ); + expect(r3.status).toBe(400); + await r3.text(); + + // Stale rule: create helper, then set rule with ghost user, then deploy + await createScript(backend, `f/${folderName}/helper`); + const staleResp = await updateFolderRules(backend, folderName, [ + { path_glob: "**", permissioned_as: "u/ghost" }, + ]); + expect(staleResp.status).toBe(200); + await staleResp.text(); + + const schedResp = await createSchedule( + backend, + `f/${folderName}/should_fail`, + `f/${folderName}/helper` + ); + expect(schedResp.status).toBe(400); + const body = await schedResp.text(); + expect(body).toContain("u/ghost"); + expect(body).toContain("does not exist"); + }); + }); +}); diff --git a/frontend/src/lib/components/FolderEditor.svelte b/frontend/src/lib/components/FolderEditor.svelte index 565104174f..8be3a6e222 100644 --- a/frontend/src/lib/components/FolderEditor.svelte +++ b/frontend/src/lib/components/FolderEditor.svelte @@ -2,6 +2,7 @@ import { userStore, workspaceStore } from '$lib/stores' import { type Folder, + type FolderDefaultPermissionedAs, FolderService, UserService, GranularAclService, @@ -13,7 +14,7 @@ import GroupEditor from './GroupEditor.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import { Eye, Plus, Trash } from 'lucide-svelte' + import { ArrowDown, ArrowUp, Eye, Plus, Trash } from 'lucide-svelte' import Label from './Label.svelte' import { sendUserToast } from '$lib/toast' import { createEventDispatcher, untrack } from 'svelte' @@ -21,6 +22,9 @@ import { safeSelectItems } from './select/utils.svelte' import TextInput from './text_input/TextInput.svelte' import PermissionHistory from './PermissionHistory.svelte' + import { Minimatch } from 'minimatch' + import Tooltip from './Tooltip.svelte' + import CollapseLink from './CollapseLink.svelte' interface Props { name: string @@ -72,6 +76,7 @@ try { folder = await FolderService.getFolder({ workspace: $workspaceStore!, name }) summary = folder.summary ?? '' + defaultPermissionedAs = (folder.default_permissioned_as ?? []).map((r) => ({ ...r })) can_write = $userStore != undefined && (folder?.owners.includes('u/' + $userStore.username) || @@ -97,6 +102,84 @@ } } + // --- default_permissioned_as rules editor --- + let defaultPermissionedAs: FolderDefaultPermissionedAs = $state([]) + + const canEditDefaults = $derived( + can_write && + ($userStore?.is_admin || + $userStore?.is_super_admin || + ($userStore?.groups ?? []).includes('wm_deployers')) + ) + + function isValidGlob(glob: string): boolean { + if (!glob) return false + try { + new Minimatch(glob) + return true + } catch { + return false + } + } + + function isValidPermissionedAs(value: string): boolean { + return value.startsWith('u/') || value.startsWith('g/') || value.includes('@') + } + + // Split a permissioned_as value like "u/alice" or "g/prod" into its kind and name. + function ruleKind(value: string): 'user' | 'group' { + return value.startsWith('g/') ? 'group' : 'user' + } + function ruleName(value: string): string { + if (value.startsWith('u/') || value.startsWith('g/')) return value.slice(2) + return value + } + function setRulePermissionedAs(idx: number, kind: 'user' | 'group', name: string) { + const prefix = kind === 'user' ? 'u/' : 'g/' + defaultPermissionedAs[idx].permissioned_as = name ? prefix + name : '' + } + + const defaultRulesInvalid = $derived( + defaultPermissionedAs.some( + (r) => !isValidGlob(r.path_glob) || !isValidPermissionedAs(r.permissioned_as) + ) + ) + + function addDefaultRule() { + defaultPermissionedAs = [...defaultPermissionedAs, { path_glob: '**', permissioned_as: '' }] + } + + function removeDefaultRule(idx: number) { + defaultPermissionedAs = defaultPermissionedAs.filter((_, i) => i !== idx) + } + + function moveDefaultRule(idx: number, delta: -1 | 1) { + const next = [...defaultPermissionedAs] + const target = idx + delta + if (target < 0 || target >= next.length) return + ;[next[idx], next[target]] = [next[target], next[idx]] + defaultPermissionedAs = next + } + + async function saveDefaultRules() { + if (defaultRulesInvalid) { + sendUserToast('Some rules have invalid globs or permissioned_as values', true) + return + } + try { + await FolderService.updateFolder({ + workspace: $workspaceStore ?? '', + name, + requestBody: { default_permissioned_as: defaultPermissionedAs } + }) + sendUserToast('Default permissioned_as rules updated') + dispatch('update') + loadFolder() + } catch (e) { + sendUserToast(e.body ?? String(e), true) + } + } + function getRole(x: string): Role { const viewer = x in (folder?.extra_perms ?? {}) const writer = viewer && (folder?.extra_perms ?? {})[x] @@ -270,14 +353,13 @@ {/if} {#if perms} - {#snippet headerRow()} - + user/group - {/snippet} + {/snippet} {#snippet body()} {#each perms ?? [] as { owner_name, role }} @@ -448,6 +530,121 @@ + {#if canEditDefaults} + +
+ + This setting is mostly relevant on production workspaces where you want + new items under this folder to run under a least-privilege service account rather than the + deploying admin's identity. When an admin or wm_deployers member creates a + trigger, schedule, app, script, or flow under this folder, the first matching rule + determines the default permissioned_as. Globs are relative to the folder root + (e.g. jobs/** matches f/{name}/jobs/run_a). Existing items are + never rewritten. + + + {#if defaultPermissionedAs.length > 0} + + {#snippet headerRow()} + + path_glob Glob relative to f/{name}/ + permissioned as + + + {/snippet} + {#snippet body()} + + {#each defaultPermissionedAs as rule, idx (idx)} + {@const kind = ruleKind(rule.permissioned_as)} + {@const itemsForKind = kind === 'user' ? usernames : groups} + + + + + +
+ setRulePermissionedAs(idx, e.detail, '')} + > + {#snippet children({ item })} + + + {/snippet} + +