From 2851b6b7caac4a55f5202ace82aba68fd157c52a Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:49:25 +0100 Subject: [PATCH] feat: restriction rulesets for workspaces (#7879) --- ...a7c0778af5e548254f3e0f29004d7f2058eef.json | 46 +++ ...71a2dba6ec66aee2971faef76b6ed56a05b6f.json | 18 + ...31386e7782c52251cf094e06ddb198a958b43.json | 15 + ...5e6bdfe82466134ba28408a530e22c66656de.json | 23 ++ ...67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1.json | 18 + backend/Cargo.lock | 1 + ...260114180000_add_protection_rules.down.sql | 2 + ...20260114180000_add_protection_rules.up.sql | 10 + backend/windmill-api-groups/src/folders.rs | 42 ++- backend/windmill-api-scripts/src/flows.rs | 55 +++- backend/windmill-api-scripts/src/scripts.rs | 83 ++++- backend/windmill-api-workspaces/Cargo.toml | 1 + .../windmill-api-workspaces/src/workspaces.rs | 298 ++++++++++++++++- backend/windmill-api/openapi.yaml | 162 ++++++++- backend/windmill-api/src/apps.rs | 73 ++++- backend/windmill-api/src/resources.rs | 4 +- backend/windmill-common/src/workspaces.rs | 202 ++++++++++++ backend/windmill-store/src/resources.rs | 99 ++++++ backend/windmill-store/src/variables.rs | 52 +++ .../lib/components/CompareWorkspaces.svelte | 45 ++- .../src/lib/components/DeployWorkspace.svelte | 8 +- .../components/DeployWorkspaceDrawer.svelte | 25 +- .../lib/components/NoDirectDeployAlert.svelte | 45 +++ .../ParentWorkspaceProtectionAlert.svelte | 75 +++++ .../lib/components/common/table/AppRow.svelte | 14 +- .../components/common/table/FlowRow.svelte | 26 +- .../components/common/table/ScriptRow.svelte | 16 +- .../components/flows/CreateActionsApp.svelte | 57 ++-- .../components/flows/CreateActionsFlow.svelte | 57 ++-- frontend/src/lib/components/home/Item.svelte | 4 + .../src/lib/components/home/ItemsList.svelte | 4 +- .../scripts/CreateActionsScript.svelte | 1 - .../src/lib/components/select/Select.svelte | 5 + .../components/sidebar/WorkspaceMenu.svelte | 3 +- .../workspaceSettings/RulesetEditor.svelte | 310 ++++++++++++++++++ .../WorkspaceRulesets.svelte | 187 +++++++++++ frontend/src/lib/storeUtils.ts | 11 + .../lib/workspaceProtectionRules.svelte.ts | 219 +++++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 8 + .../src/routes/(root)/(logged)/+page.svelte | 12 +- .../(logged)/flows/get/[...path]/+page.svelte | 14 +- .../(root)/(logged)/resources/+page.svelte | 56 ++-- .../(root)/(logged)/run/[...run]/+page.svelte | 4 + .../scripts/get/[...hash]/+page.svelte | 72 ++-- .../(root)/(logged)/variables/+page.svelte | 52 +-- .../(logged)/workspace_settings/+page.svelte | 15 + 46 files changed, 2338 insertions(+), 211 deletions(-) create mode 100644 backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json create mode 100644 backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json create mode 100644 backend/.sqlx/query-7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43.json create mode 100644 backend/.sqlx/query-b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de.json create mode 100644 backend/.sqlx/query-dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1.json create mode 100644 backend/migrations/20260114180000_add_protection_rules.down.sql create mode 100644 backend/migrations/20260114180000_add_protection_rules.up.sql create mode 100644 frontend/src/lib/components/NoDirectDeployAlert.svelte create mode 100644 frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte create mode 100644 frontend/src/lib/workspaceProtectionRules.svelte.ts diff --git a/backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json b/backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json new file mode 100644 index 0000000000..abe21b0930 --- /dev/null +++ b/backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n name,\n rules as \"rules: ProtectionRules\",\n bypass_groups,\n bypass_users\n FROM workspace_protection_rule\n WHERE workspace_id = $1\n ORDER BY name\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "rules: ProtectionRules", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "bypass_groups", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "bypass_users", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef" +} diff --git a/backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json b/backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json new file mode 100644 index 0000000000..5badb301e6 --- /dev/null +++ b/backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_protection_rule\n SET rules = $1, bypass_groups = $2, bypass_users = $3\n WHERE workspace_id = $4 AND name = $5\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "TextArray", + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f" +} diff --git a/backend/.sqlx/query-7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43.json b/backend/.sqlx/query-7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43.json new file mode 100644 index 0000000000..cf8800d900 --- /dev/null +++ b/backend/.sqlx/query-7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43" +} diff --git a/backend/.sqlx/query-b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de.json b/backend/.sqlx/query-b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de.json new file mode 100644 index 0000000000..0bafcb2f18 --- /dev/null +++ b/backend/.sqlx/query-b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de" +} diff --git a/backend/.sqlx/query-dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1.json b/backend/.sqlx/query-dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1.json new file mode 100644 index 0000000000..ae5792117d --- /dev/null +++ b/backend/.sqlx/query-dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int4", + "TextArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dead88df84..3cc499bf2b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16088,6 +16088,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", + "strum 0.27.2", "tokio", "tracing", "uuid", diff --git a/backend/migrations/20260114180000_add_protection_rules.down.sql b/backend/migrations/20260114180000_add_protection_rules.down.sql new file mode 100644 index 0000000000..869df150f9 --- /dev/null +++ b/backend/migrations/20260114180000_add_protection_rules.down.sql @@ -0,0 +1,2 @@ +-- Drop the workspace_protection_rule table and its indexes +DROP TABLE IF EXISTS workspace_protection_rule; diff --git a/backend/migrations/20260114180000_add_protection_rules.up.sql b/backend/migrations/20260114180000_add_protection_rules.up.sql new file mode 100644 index 0000000000..c29370b73a --- /dev/null +++ b/backend/migrations/20260114180000_add_protection_rules.up.sql @@ -0,0 +1,10 @@ +-- Add workspace_protection_rule table for fine-grained access control +CREATE TABLE workspace_protection_rule ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + rules INTEGER NOT NULL, + bypass_groups TEXT[] NOT NULL DEFAULT '{}', + bypass_users TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, name) +); diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 1a4d530462..d7ea3590b5 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -16,9 +16,9 @@ use axum::{ use lazy_static::lazy_static; use regex::Regex; use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened}; -use windmill_audit::audit_oss::audit_log; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; -use windmill_common::webhook::{WebhookMessage, WebhookShared}; +use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}}; use windmill_common::DB; use windmill_common::{ db::UserDB, @@ -174,6 +174,19 @@ async fn create_folder( Path(w_id): Path, Json(ng): Json, ) -> Result { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut tx = user_db.clone().begin(&authed).await?; if !VALID_FOLDER_NAME.is_match(&ng.name) { @@ -315,6 +328,19 @@ async fn update_folder( ) -> Result { use sql_builder::prelude::*; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut sqlb = SqlBuilder::update_table("folder"); sqlb.and_where_eq("name", "?".bind(&name)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); @@ -604,6 +630,18 @@ async fn delete_folder( Extension(webhook): Extension, Path((w_id, name)): Path<(String, String)>, ) -> Result { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?; diff --git a/backend/windmill-api-scripts/src/flows.rs b/backend/windmill-api-scripts/src/flows.rs index adeb24d068..ab963ee531 100644 --- a/backend/windmill-api-scripts/src/flows.rs +++ b/backend/windmill-api-scripts/src/flows.rs @@ -18,6 +18,7 @@ use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; +use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; use windmill_common::{ utils::{WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, @@ -29,7 +30,7 @@ use hyper::StatusCode; use serde::{Deserialize, Serialize}; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; -use windmill_audit::audit_oss::audit_log; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; use windmill_common::min_version::{ @@ -429,6 +430,20 @@ async fn create_flow( )); } check_scopes(&authed, || format!("flows:write:{}", nf.path))?; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + validate_flow(&nf).await?; if *CLOUD_HOSTED { let nb_flows = @@ -863,6 +878,20 @@ async fn update_flow( } let flow_path = flow_path.to_path(); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + validate_flow(&nf).await?; let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; @@ -1417,6 +1446,18 @@ async fn archive_flow_by_path( ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; sqlx::query!( @@ -1522,6 +1563,18 @@ async fn delete_flow_by_path( ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; sqlx::query!( diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index f73ea4c04b..96968605b1 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -12,9 +12,7 @@ use windmill_api_auth::{ check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; use windmill_common::{ - utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, - webhook::{WebhookMessage, WebhookShared}, - DB, + utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, DB }; use windmill_queue::schedule::clear_schedule; @@ -35,7 +33,7 @@ use serde_json::value::RawValue; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; use std::{collections::HashMap, sync::Arc}; -use windmill_audit::audit_oss::audit_log; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_dep_map::process_relative_imports; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; @@ -548,6 +546,18 @@ async fn create_script( Path(w_id): Path, Json(ns): Json, ) -> Result<(StatusCode, String)> { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let (hash, tx, hdm) = create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?; tx.commit().await?; @@ -1906,6 +1916,18 @@ async fn archive_script_by_path( } let path = path.to_path(); check_scopes(&authed, || format!("scripts:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; require_owner_of_path(&authed, path)?; @@ -1973,6 +1995,18 @@ async fn archive_script_by_hash( "Operators cannot archive scripts for security reasons".to_string(), )); } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; let script = sqlx::query_as::<_, Script>( @@ -2018,9 +2052,20 @@ async fn delete_script_by_hash( Extension(db): Extension, Path((w_id, hash)): Path<(String, ScriptHash)>, ) -> JsonResult> { - let mut tx = user_db.begin(&authed).await?; - require_admin(authed.is_admin, &authed.username)?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut tx = user_db.begin(&authed).await?; let script = sqlx::query_as::<_, Script>( "UPDATE script SET content = '', archived = true, deleted = true, lock = '', schema = null WHERE hash = $1 AND \ workspace_id = $2 RETURNING *", @@ -2078,6 +2123,19 @@ async fn delete_script_by_path( )); } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut tx = user_db.begin(&authed).await?; let draft_only = sqlx::query_scalar!( @@ -2204,6 +2262,19 @@ async fn delete_scripts_bulk( )); } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut tx = db.begin().await?; let mut deleted_paths = sqlx::query_scalar!( diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index fd07e893be..1caf13e7f6 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -39,3 +39,4 @@ sqlx.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true +strum.workspace = true diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index b7fb588b5c..f3581b787a 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -26,7 +26,8 @@ use hex; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; -use windmill_audit::audit_oss::audit_log; +use strum::{IntoEnumIterator}; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::s3_helpers::LargeFileStorage; @@ -38,8 +39,7 @@ use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ - get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, - WorkspaceGitSyncSettings, + check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; @@ -147,6 +147,12 @@ pub fn workspaced_service() -> Router { post(reset_workspace_diffs), ) .route("/compare/:target_workspace_id", get(compare_workspaces)) + .route("/protection_rules", get(list_protection_rules)) + .route("/protection_rules", post(create_protection_rule)) + .route( + "/protection_rules/:rule_name", + post(update_protection_rule).delete(delete_protection_rule), + ) } pub fn global_service() -> Router { Router::new() @@ -3382,6 +3388,18 @@ async fn create_workspace_fork_branch( if *DISABLE_WORKSPACE_FORK { require_super_admin(&db, &authed.email).await?; } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableWorkspaceForking, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } Ok(Json( handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, @@ -3403,6 +3421,18 @@ async fn create_workspace_fork( if *DISABLE_WORKSPACE_FORK { require_super_admin(&db, &authed.email).await?; } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &parent_workspace_id, + &ProtectionRuleKind::DisableWorkspaceForking, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx: Transaction<'_, Postgres> = db.begin().await?; @@ -4285,6 +4315,268 @@ async fn update_operator_settings( Ok("Operator settings updated successfully".to_string()) } +// Protection Rules API endpoints + +#[derive(Deserialize)] +struct CreateProtectionRuleRequest { + name: String, + rules: Vec, + bypass_groups: Vec, + bypass_users: Vec, +} + +#[derive(Deserialize)] +struct UpdateProtectionRuleRequest { + rules: Vec, + bypass_groups: Vec, + bypass_users: Vec, +} + +#[derive(Serialize)] +struct ProtectionRulesetResponse { + pub workspace_id: String, + pub name: String, + pub rules: Vec, + pub bypass_groups: Vec, + pub bypass_users: Vec, +} + +impl From for ProtectionRulesetResponse { + fn from(value: ProtectionRuleset) -> Self { + let mut rules = vec![]; + + for rule in ProtectionRuleKind::iter() { + if value.rules.contains(rule.flag()) { + rules.push(rule) + } + } + + ProtectionRulesetResponse { + rules, + workspace_id: value.workspace_id, + name: value.name, + bypass_groups: value.bypass_groups, + bypass_users: value.bypass_users, + } + } +} + +/// List all protection rules for a workspace +async fn list_protection_rules( + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let rules = + (*windmill_common::workspaces::get_protection_rules(&w_id, &db).await?).clone(); + Ok(Json(rules.into_iter().map(ProtectionRulesetResponse::from).collect())) +} + +/// Create a new protection rule +async fn create_protection_rule( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + // Check if rule with this name already exists + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)", + &w_id, + &req.name + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if exists { + return Err(Error::BadRequest(format!( + "Protection rule with name '{}' already exists", + req.name + ))); + } + + // Insert the new rule + sqlx::query!( + r#" + INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users) + VALUES ($1, $2, $3, $4, $5) + "#, + &w_id, + &req.name, + ProtectionRules::from(&req.rules).bits(), + &req.bypass_groups, + &req.bypass_users, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.create_protection_rule", + ActionKind::Create, + &w_id, + Some(&req.name), + Some([("name", &req.name[..])].into()), + ) + .await?; + + tx.commit().await?; + + // Invalidate cache + windmill_common::workspaces::invalidate_protection_rules_cache(&w_id); + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: format!("protection_rule_{}", req.name) }, + None, + false, + None, + ) + .await?; + + Ok(format!("Created protection rule '{}'", req.name)) +} + +/// Update an existing protection rule +async fn update_protection_rule( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, rule_name)): Path<(String, String)>, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + // Check if rule exists + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)", + &w_id, + &rule_name + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if !exists { + return Err(Error::NotFound(format!( + "Protection rule '{}' not found", + rule_name + ))); + } + + // Update the rule + sqlx::query!( + r#" + UPDATE workspace_protection_rule + SET rules = $1, bypass_groups = $2, bypass_users = $3 + WHERE workspace_id = $4 AND name = $5 + "#, + ProtectionRules::from(&req.rules).bits(), + &req.bypass_groups, + &req.bypass_users, + &w_id, + &rule_name + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.update_protection_rule", + ActionKind::Update, + &w_id, + Some(&rule_name), + Some([("name", &rule_name[..])].into()), + ) + .await?; + + tx.commit().await?; + + // Invalidate cache + windmill_common::workspaces::invalidate_protection_rules_cache(&w_id); + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) }, + None, + false, + None, + ) + .await?; + + Ok(format!("Updated protection rule '{}'", rule_name)) +} + +/// Delete a protection rule +async fn delete_protection_rule( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, rule_name)): Path<(String, String)>, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + // Delete the rule + let result = sqlx::query!( + "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2", + &w_id, + &rule_name + ) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + return Err(Error::NotFound(format!( + "Protection rule '{}' not found", + rule_name + ))); + } + + audit_log( + &mut *tx, + &authed, + "workspaces.delete_protection_rule", + ActionKind::Delete, + &w_id, + Some(&rule_name), + Some([("name", &rule_name[..])].into()), + ) + .await?; + + tx.commit().await?; + + // Invalidate cache + windmill_common::workspaces::invalidate_protection_rules_cache(&w_id); + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) }, + None, + false, + None, + ) + .await?; + + Ok(format!("Deleted protection rule '{}'", rule_name)) +} + #[derive(Serialize)] pub struct WorkspaceComparison { pub all_ahead_items_visible: bool, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 367c24c6de..ed88e6a321 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4169,6 +4169,126 @@ paths: type: string example: "Updated mute critical alert UI settings for workspace: workspace_id" + /w/{workspace}/workspaces/protection_rules: + get: + summary: list all protection rules for a workspace + operationId: listProtectionRules + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: list of protection rules + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ProtectionRuleset" + post: + summary: create a new protection rule + operationId: createProtectionRule + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: New protection rule configuration + required: true + content: + application/json: + schema: + type: object + required: + - name + - rules + - bypass_groups + - bypass_users + properties: + name: + type: string + description: Unique name for the protection rule + example: "Production Protection" + rules: + $ref: "#/components/schemas/ProtectionRules" + bypass_groups: + $ref: "#/components/schemas/RuleBypasserGroups" + bypass_users: + $ref: "#/components/schemas/RuleBypasserUsers" + responses: + "200": + description: protection rule created successfully + content: + text/plain: + schema: + type: string + "400": + description: rule with this name already exists + + /w/{workspace}/workspaces/protection_rules/{rule_name}: + post: + summary: update an existing protection rule + operationId: updateProtectionRule + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: rule_name + in: path + required: true + schema: + type: string + description: Name of the protection rule to update + requestBody: + description: Updated protection rule configuration + required: true + content: + application/json: + schema: + type: object + required: + - rules + - bypass_groups + - bypass_users + properties: + rules: + $ref: "#/components/schemas/ProtectionRules" + bypass_groups: + $ref: "#/components/schemas/RuleBypasserGroups" + bypass_users: + $ref: "#/components/schemas/RuleBypasserUsers" + responses: + "200": + description: protection rule updated successfully + content: + text/plain: + schema: + type: string + "404": + description: protection rule not found + delete: + summary: delete a protection rule + operationId: deleteProtectionRule + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: rule_name + in: path + required: true + schema: + type: string + description: Name of the protection rule to delete + responses: + "200": + description: protection rule deleted successfully + content: + text/plain: + schema: + type: string + "404": + description: protection rule not found /w/{workspace}/workspaces/public_app_rate_limit: post: summary: Set public app rate limit for this workspace @@ -22983,7 +23103,47 @@ components: kind: $ref: "#/components/schemas/AssetKind" required: [path, kind] - + ProtectionRuleset: + type: object + description: A workspace protection rule defining restrictions and bypass permissions + required: + - name + - rules + - bypass_groups + - bypass_users + properties: + name: + type: string + description: Unique name for the protection rule + example: "Production Protection" + workspace_id: + type: string + rules: + $ref: "#/components/schemas/ProtectionRules" + bypass_groups: + $ref: "#/components/schemas/RuleBypasserGroups" + bypass_users: + $ref: "#/components/schemas/RuleBypasserUsers" + ProtectionRules: + type: array + description: Configuration of protection restrictions + items: + $ref: "#/components/schemas/ProtectionRuleKind" + ProtectionRuleKind: + type: string + enum: + - DisableDirectDeployment + - DisableWorkspaceForking + RuleBypasserGroups: + type: array + description: Groups that can bypass this ruleset + items: + type: string + RuleBypasserUsers: + type: array + description: Users that can bypass this ruleset + items: + type: string NativeServiceName: type: string enum: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 6e9352e8ee..43fe926884 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -49,7 +49,7 @@ use sha2::{Digest, Sha256}; use sql_builder::{bind::Bind, SqlBuilder}; use sqlx::{types::Uuid, FromRow}; use std::str; -use windmill_audit::audit_oss::audit_log; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::{ apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE}, @@ -65,6 +65,7 @@ use windmill_common::{ }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, worker::{to_raw_value, CLOUD_HOSTED}, + workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, HUB_BASE_URL, }; @@ -1049,6 +1050,20 @@ async fn create_app_raw<'a>( "Operators cannot create apps for security reasons".to_string(), )); } + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let (path, _id) = process_app_multipart!( authed, user_db, @@ -1109,6 +1124,19 @@ async fn create_app( let path = app.path.clone(); check_scopes(&authed, || format!("apps:write:{}", &path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let (new_tx, _path, _id) = create_app_internal(authed, db, user_db, &w_id, false, app).await?; new_tx.commit().await?; @@ -1347,6 +1375,19 @@ async fn delete_app( )); } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + // Check if it's a raw app before deletion let is_raw_app = sqlx::query_scalar!( "SELECT app_version.raw_app FROM app @@ -1452,6 +1493,21 @@ async fn update_app( // create_app_internal(authed, user_db, db, &w_id, &mut app).await?; let path = path.to_path(); check_scopes(&authed, || format!("apps:write:{}", path))?; + + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let opath = path.to_string(); let (new_tx, npath, _v_id) = update_app_internal(authed, db, user_db, &w_id, path, false, ns).await?; @@ -1482,6 +1538,21 @@ async fn update_app_raw<'a>( "Operators cannot update apps for security reasons".to_string(), )); } + + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let path = path.to_path(); check_scopes(&authed, || format!("apps:write:{}", path))?; let opath = path.to_string(); diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index 83203233fc..bc0380b2eb 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -2,9 +2,7 @@ pub use windmill_store::resources::*; #[cfg(feature = "mcp")] -use axum::{ - extract::{Extension, Path}, - routing::get, +use axum::{ extract::{Extension, Path}, routing::get, Json, Router, }; #[cfg(feature = "mcp")] diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 4cbb569431..8c4d94ef15 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -13,6 +13,103 @@ use crate::{ PgDatabase, DB, }; +macro_rules! sqlx_bitflags { + ( + $flags:ty => $repr:ty + ) => { + // ---- Type ---- + impl sqlx::Type for $flags { + fn type_info() -> sqlx::postgres::PgTypeInfo { + <$repr as sqlx::Type>::type_info() + } + } + + // ---- Encode ---- + impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $flags { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> std::result::Result> + { + let bits: $repr = self.bits(); + <$repr as sqlx::Encode>::encode(bits, buf) + } + } + + // ---- Decode ---- + impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $flags { + fn decode( + value: sqlx::postgres::PgValueRef<'r>, + ) -> std::result::Result> { + let bits = <$repr as sqlx::Decode>::decode(value)?; + <$flags>::from_bits(bits) + .ok_or_else(|| "invalid bitflags value from database".into()) + } + } + }; +} + +// Protection Rules - for fine-grained workspace access control + +/// API representation of a protection rule +#[derive(Debug, Clone)] +pub struct ProtectionRuleset { + pub workspace_id: String, + pub name: String, + pub rules: ProtectionRules, + pub bypass_groups: Vec, + pub bypass_users: Vec, +} + +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + // #[sqlx(transparent)] + pub struct ProtectionRules: i32 { + const DISABLE_DIRECT_DEPLOYMENT = 1 << 0; + const DISABLE_WORKSPACE_FORKING = 1 << 1; + } +} + +sqlx_bitflags!(ProtectionRules => i32); + +#[derive(Serialize, Deserialize, strum_macros::EnumIter)] +pub enum ProtectionRuleKind { + DisableDirectDeployment, + DisableWorkspaceForking, +} + +impl ProtectionRuleKind { + pub const fn flag(&self) -> ProtectionRules { + match self { + ProtectionRuleKind::DisableDirectDeployment => { + ProtectionRules::DISABLE_DIRECT_DEPLOYMENT + } + ProtectionRuleKind::DisableWorkspaceForking => { + ProtectionRules::DISABLE_WORKSPACE_FORKING + } + } + } + + pub const fn msg(&self) -> &str { + match self { + ProtectionRuleKind::DisableDirectDeployment => { + "Cannot directly deploy in this workspace. Fork or Pull request required." + } + ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden", + } + } +} + +impl From<&Vec> for ProtectionRules { + fn from(value: &Vec) -> Self { + let mut r = ProtectionRules::empty(); + for rule in value { + r = r | rule.flag(); + } + r + } +} + #[derive(Serialize, Deserialize, Debug, Default)] pub struct WorkspaceGitSyncSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -176,6 +273,111 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result>, i64)> = Cache::new(100); +} + +/// Get all protection rules for a workspace with caching (60s TTL) +pub async fn get_protection_rules( + workspace_id: &str, + db: &DB, +) -> Result>> { + let now = chrono::Utc::now().timestamp(); + + // Check cache and expiry + if let Some((cached_rules, expiry)) = PROTECTION_RULES_CACHE.get(workspace_id) { + if expiry > now { + return Ok(cached_rules); + } + } + + // Query database + let rulesets = sqlx::query_as!( + ProtectionRuleset, + r#" + SELECT + workspace_id, + name, + rules as "rules: ProtectionRules", + bypass_groups, + bypass_users + FROM workspace_protection_rule + WHERE workspace_id = $1 + ORDER BY name + "#, + workspace_id + ) + .fetch_all(db) + .await + .map_err(|e| Error::internal_err(format!("Failed to fetch protection rules: {}", e)))?; + + // Cache with 60s TTL + let arc_rules = std::sync::Arc::new(rulesets); + let expiry = now + 60; + PROTECTION_RULES_CACHE.insert(workspace_id.to_string(), (arc_rules.clone(), expiry)); + + Ok(arc_rules) +} + +/// Invalidate the protection rules cache for a workspace +pub fn invalidate_protection_rules_cache(workspace_id: &str) { + PROTECTION_RULES_CACHE.remove(workspace_id); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleCheckResult { + Allowed, + Blocked(String), +} + +/// Check if a user can bypass a protection rule +/// +/// Returns `Allowed` if: +/// - User is in the rule's bypass users list (u/) +/// - User's group is in the rule's bypass groups list (g/) +/// +/// Returns `Blocked` if: +/// - User is not in bypass lists +/// +/// Returns `Err` if the rule is not found +pub async fn check_user_against_rule( + workspace_id: &str, + rule: &ProtectionRuleKind, + username: &str, + user_groups: &[String], + is_admin: bool, + db: &DB, +) -> Result { + if is_admin { + return Ok(RuleCheckResult::Allowed); + } + + let rulesets = get_protection_rules(workspace_id, db).await?; + + for ruleset in rulesets.iter() { + if ruleset.rules.contains(rule.flag()) { + if ruleset.bypass_users.iter().any(|u| u == username) + || ruleset + .bypass_groups + .iter() + .any(|g| user_groups.contains(g)) + { + continue; + } + return Ok(RuleCheckResult::Blocked(format!( + "Ruleset {} of {} blocked this action: {}", + ruleset.name, + workspace_id, + rule.msg() + ))); + } + } + + Ok(RuleCheckResult::Allowed) +} + #[derive(Deserialize, Serialize, Debug)] pub struct DataTable { pub database: DataTableDatabase, diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 262908d649..8fa04f7780 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -13,6 +13,7 @@ use windmill_api_auth::{ Tokened, }; use windmill_common::db::DB; +use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; use crate::var_resource_cache::{cache_resource, get_cached_resource}; @@ -683,6 +684,18 @@ async fn create_resource( Json(resource): Json, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || format!("resources:write:{}", resource.path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } if *CLOUD_HOSTED { let nb_resources = sqlx::query_scalar!( "SELECT COUNT(*) FROM resource WHERE workspace_id = $1", @@ -793,6 +806,18 @@ async fn delete_resource( let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; let deleted_path = sqlx::query_scalar!( @@ -854,6 +879,19 @@ async fn delete_resources_bulk( check_scopes(&authed, || format!("resources:write:{}", path))?; } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut tx = user_db.begin(&authed).await?; let deleted_paths = sqlx::query_scalar!( @@ -916,6 +954,18 @@ async fn update_resource( let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut sqlb = SqlBuilder::update_table("resource"); sqlb.and_where_eq("path", "?".bind(&path)); @@ -1042,6 +1092,18 @@ async fn update_resource_value( ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; sqlx::query!( @@ -1193,6 +1255,19 @@ async fn create_resource_type( Path(w_id): Path, Json(resource_type): Json, ) -> Result<(StatusCode, String)> { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let mut tx = user_db.begin(&authed).await?; check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?; @@ -1279,6 +1354,18 @@ async fn delete_resource_type( Path((w_id, name)): Path<(String, String)>, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut tx = user_db.begin(&authed).await?; @@ -1333,6 +1420,18 @@ async fn update_resource_type( Json(ns): Json, ) -> Result { use sql_builder::prelude::*; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } let mut sqlb = SqlBuilder::update_table("resource_type"); sqlb.and_where_eq("name", "?".bind(&name)); diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 9c3dd652db..7d83a97489 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -8,6 +8,7 @@ use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed}; use windmill_common::db::DB; +use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; use crate::secret_backend_ext::{ delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret, @@ -339,6 +340,19 @@ async fn create_variable( Json(variable): Json, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || format!("variables:write:{}", variable.path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + if *CLOUD_HOSTED { let nb_variables = sqlx::query_scalar!( "SELECT COUNT(*) FROM variable WHERE workspace_id = $1", @@ -438,6 +452,18 @@ async fn delete_variable( let path = path.to_path(); check_scopes(&authed, || format!("variables:write:{}", path))?; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } // Check if variable is a secret before deleting (for Vault cleanup) let is_secret = sqlx::query_scalar!( @@ -515,6 +541,19 @@ async fn delete_variables_bulk( check_scopes(&authed, || format!("variables:write:{}", path))?; } + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + // Query which paths are secrets before deletion (for Vault cleanup) let secret_paths: Vec = sqlx::query_scalar!( "SELECT path FROM variable WHERE path = ANY($1) AND workspace_id = $2 AND is_secret = true", @@ -613,6 +652,19 @@ async fn update_variable( ) -> Result { use sql_builder::prelude::*; + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &ProtectionRuleKind::DisableDirectDeployment, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let path = path.to_path(); check_scopes(&authed, || format!("variables:write:{}", path))?; let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await; diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 4d36600295..61da1b6b11 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -26,6 +26,7 @@ } from '$lib/gen' import Button from './common/button/Button.svelte' import DiffDrawer from './DiffDrawer.svelte' + import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' import { getAllModules } from './flows/flowExplorer' import { userWorkspaces, workspaceStore } from '$lib/stores' @@ -51,6 +52,7 @@ let mergeIntoParent = $state(true) let deploying = $state(false) let hasAutoSelected = $state(false) + let canDeployToParent = $state(true) let selectableDiffs = $derived( comparison?.diffs.filter((diff) => { @@ -783,6 +785,14 @@ + {#if mergeIntoParent} + { + canDeployToParent = canDeploy + }} + /> + {/if} {#if conflictingDiffs.length > 0} @@ -793,7 +803,7 @@ {/if} - {#if hasBehindChanges && hasAheadChanges} + {#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)} {#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible} - + {#if !(mergeIntoParent && !canDeployToParent)} + + {/if} {/if} {#if deploymentErrorMessage != ''} @@ -1022,6 +1035,4 @@
No comparison data available
{/if} - - diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index c7c5ba2ca0..dd16a3e1d7 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -43,6 +43,7 @@ additionalInformation?: AdditionalInformation | undefined workspaceToDeployTo?: string | undefined hideButton?: boolean + canDeployToWorkspace?: boolean } let { @@ -50,7 +51,8 @@ initialPath = '', additionalInformation = undefined, workspaceToDeployTo = $bindable(undefined), - hideButton = false + hideButton = false, + canDeployToWorkspace = $bindable(false) }: Props = $props() let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined = @@ -735,7 +737,7 @@ > {/if} {:else} - + {/if} {/each} @@ -743,7 +745,7 @@ {#if !hideButton}
{/if} {:else if canSeeTarget == 'cant-see-all-deps'} diff --git a/frontend/src/lib/components/DeployWorkspaceDrawer.svelte b/frontend/src/lib/components/DeployWorkspaceDrawer.svelte index 6a83d96393..ceae074847 100644 --- a/frontend/src/lib/components/DeployWorkspaceDrawer.svelte +++ b/frontend/src/lib/components/DeployWorkspaceDrawer.svelte @@ -2,14 +2,16 @@ import { Button, Drawer } from './common' import DrawerContent from './common/drawer/DrawerContent.svelte' import DeployWorkspace from './DeployWorkspace.svelte' + import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' import { type AdditionalInformation, type Kind } from '$lib/utils_deployable' - let initialPath: string | undefined = undefined - let kind: Kind | undefined = undefined - let drawer: Drawer | undefined = undefined - let workspaceToDeployTo: string | undefined = undefined - let deployWorkspace: DeployWorkspace | undefined = undefined - let additionalInformation: AdditionalInformation | undefined = undefined + let initialPath: string | undefined = $state(undefined) + let kind: Kind | undefined = $state(undefined) + let drawer: Drawer | undefined = $state(undefined) + let workspaceToDeployTo: string | undefined = $state(undefined) + let deployWorkspace: DeployWorkspace | undefined = $state(undefined) + let additionalInformation: AdditionalInformation | undefined = $state(undefined) + let canDeployToWorkspace = $state(true) export async function openDrawer( initialPath_l: string, @@ -25,6 +27,14 @@ + {#if workspaceToDeployTo} + { + canDeployToWorkspace = canDeploy + }} + /> + {/if} {#if (kind != 'trigger' && kind != undefined && initialPath != undefined) || (kind === 'trigger' && initialPath != undefined && additionalInformation?.triggers != undefined)} {/if} {#snippet actions()} {/snippet} diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte new file mode 100644 index 0000000000..69964fb98f --- /dev/null +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -0,0 +1,45 @@ + + +{#if !$userStore?.operator && activeDeployRulesets.length > 0} +
+ +
+

+ The rule{activeDeployRulesets.length > 1 ? "s" : ""} {activeDeployRulesets.map((r) => r.name).join(', ')} restrict{activeDeployRulesets.length > 1 ? "" : "s"} direct edits to + this workspace. You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user. +

+ {#if canBypass} + + {/if} +
+
+
+{/if} diff --git a/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte b/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte new file mode 100644 index 0000000000..9712062efb --- /dev/null +++ b/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte @@ -0,0 +1,75 @@ + + +{#if !$userStore?.operator && activeDeployRulesets.length > 0} + +
+

+ The workspace {parentWorkspaceId} has a protection rule{activeDeployRulesets.length > 1 ? 's' : ''} + {activeDeployRulesets.map((r) => r.name).join(', ')} + that restrict{activeDeployRulesets.length > 1 ? '' : 's'} direct deployments. You need to merge changes through the synced git repo with Git Sync, or by asking a user with the rights to bypass this rule. +

+ {#if canBypass} + + {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index e83a8ae694..529b50ea0b 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -42,6 +42,7 @@ deleteConfirmedCallback: (() => void) | undefined depth?: number menuOpen?: boolean + showEditButton?: boolean } let { @@ -52,7 +53,8 @@ deploymentDrawer, deleteConfirmedCallback = $bindable(), depth = 0, - menuOpen = $bindable(false) + menuOpen = $bindable(false), + showEditButton = $bindable(true) }: Props = $props() const dispatch = createEventDispatcher() @@ -95,7 +97,7 @@ {/snippet} {#snippet actions()}