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-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" 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/migrations/20260114180000_add_protection_rules.down.sql b/backend/migrations/20260114180000_add_protection_rules.down.sql new file mode 100644 index 0000000000..8fc3444e63 --- /dev/null +++ b/backend/migrations/20260114180000_add_protection_rules.down.sql @@ -0,0 +1,5 @@ +-- Drop the workspace_protection_rule table and its indexes +DROP INDEX IF EXISTS idx_protection_rule_bypass_users; +DROP INDEX IF EXISTS idx_protection_rule_bypass_groups; +DROP INDEX IF EXISTS idx_protection_rule_workspace; +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..d2cd011b33 --- /dev/null +++ b/backend/migrations/20260114180000_add_protection_rules.up.sql @@ -0,0 +1,13 @@ +-- 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) +); + +-- Index for listing all rules in a workspace +CREATE INDEX idx_protection_rule_workspace ON workspace_protection_rule(workspace_id); diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index f0672666d0..ffdc0cecfe 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -32,7 +32,7 @@ http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_e static_frontend = ["dep:rust-embed"] postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"] mqtt_trigger = ["dep:thiserror", "dep:rumqttc"] -native_trigger = ["dep:strum", "dep:backon", "oauth2"] +native_trigger = ["dep:backon", "oauth2"] sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:thiserror", "dep:backon", "dep:aws-config"] deno_core = ["dep:deno_core", "dep:deno_error"] gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"] @@ -151,6 +151,7 @@ aws-sdk-sso = { workspace = true, optional = true } aws-sdk-ssooidc = { workspace = true, optional = true } aws-sdk-sts = { workspace = true, optional = true } rustls = { workspace = true } +strum.workspace = true aws-sigv4.workspace = true aws-sdk-config.workspace = true @@ -168,7 +169,6 @@ deno_core = { workspace = true, optional = true } tar.workspace = true flate2.workspace = true backon = {workspace = true, optional = true} -strum = { workspace = true, optional = true } dashmap.workspace = true [build-dependencies] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ee1a974346..096f834615 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3780,6 +3780,12 @@ paths: in: query schema: type: boolean + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: new variable required: true @@ -3883,6 +3889,12 @@ paths: in: query schema: type: boolean + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: updated variable required: true @@ -4155,6 +4167,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 @@ -4615,6 +4747,12 @@ paths: in: query schema: type: boolean + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: new resource required: true @@ -4690,6 +4828,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: updated resource required: true @@ -4714,6 +4858,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: updated resource required: true @@ -4961,6 +5111,12 @@ paths: - resource parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: new resource_type required: true @@ -5017,6 +5173,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: updated resource_type required: true @@ -5922,6 +6084,12 @@ paths: - script parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: Partially filled script required: true @@ -7547,6 +7715,12 @@ paths: - flow parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: Partially filled flow required: true @@ -7578,6 +7752,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: Partially filled flow required: true @@ -7893,6 +8073,12 @@ paths: - app parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: new app required: true @@ -7935,6 +8121,12 @@ paths: - app parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: new app required: true @@ -8323,6 +8515,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: update app required: true @@ -8359,6 +8557,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: update app required: true @@ -14023,6 +14227,12 @@ paths: - folder parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: create folder required: true @@ -14061,6 +14271,12 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Name" + - name: deployed_from_workspace + in: query + description: "Optional. The workspace ID from which this deployment originates (when deploying from a fork)" + required: false + schema: + type: string requestBody: description: update folder required: true @@ -23006,7 +23222,46 @@ 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" + 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: + - RequireForkOrBranchToDeploy + - DisableWorkspaceForking + - DisableMergeUIInForks + 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 9d2d6d9db3..c33826be08 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, }; @@ -215,6 +216,11 @@ pub struct AppHistoryUpdate { pub deployment_msg: Option, } +#[derive(Deserialize)] +struct DeployedFromQuery { + deployed_from_workspace: Option, +} + pub type StaticFields = HashMap>; pub type OneOfFields = HashMap>>; pub type AllowUserResources = Vec; @@ -1042,6 +1048,7 @@ async fn create_app_raw<'a>( Extension(db): Extension, Extension(webhook): Extension, Path(w_id): Path, + Query(deployed_from): Query, multipart: Multipart, ) -> Result<(StatusCode, String)> { if authed.is_operator { @@ -1049,6 +1056,26 @@ async fn create_app_raw<'a>( "Operators cannot create apps for security reasons".to_string(), )); } + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let (path, _id) = process_app_multipart!( authed, user_db, @@ -1099,6 +1126,7 @@ async fn create_app( Extension(db): Extension, Extension(webhook): Extension, Path(w_id): Path, + Query(deployed_from): Query, Json(app): Json, ) -> Result<(StatusCode, String)> { if authed.is_operator { @@ -1109,6 +1137,25 @@ async fn create_app( let path = app.path.clone(); check_scopes(&authed, || format!("apps:write:{}", &path))?; + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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?; @@ -1411,7 +1458,7 @@ async fn delete_app( deployed_object, Some(format!("App '{}' deleted", path)), true, - None, + None, ) .await?; @@ -1442,6 +1489,7 @@ async fn update_app( Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(deployed_from): Query, Json(ns): Json, ) -> Result { if authed.is_operator { @@ -1452,6 +1500,26 @@ 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))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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?; @@ -1474,6 +1542,7 @@ async fn update_app_raw<'a>( Extension(user_db): Extension, Extension(db): Extension, Extension(webhook): Extension, + Query(deployed_from): Query, Path((w_id, path)): Path<(String, StripPath)>, multipart: Multipart, ) -> Result { @@ -1482,6 +1551,26 @@ async fn update_app_raw<'a>( "Operators cannot update apps for security reasons".to_string(), )); } + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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/flows.rs b/backend/windmill-api/src/flows.rs index fbfd64e2ef..47c693277a 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -30,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::{ @@ -39,6 +39,7 @@ use windmill_common::min_version::{ use windmill_common::runnable_settings::RunnableSettingsTrait; use windmill_common::utils::query_elems_from_hub; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; +use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; use windmill_common::HUB_BASE_URL; use windmill_common::{ db::UserDB, @@ -417,12 +418,18 @@ async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { return Ok(()); } +#[derive(Deserialize)] +struct DeployedFromQuery { + deployed_from_workspace: Option, +} + async fn create_flow( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Extension(webhook): Extension, Path(w_id): Path, + Query(deployed_from): Query, Json(nf): Json, ) -> Result<(StatusCode, String)> { if authed.is_operator { @@ -430,7 +437,28 @@ async fn create_flow( "Operators cannot create flows for security reasons".to_string(), )); } + check_scopes(&authed, || format!("flows:write:{}", nf.path))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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 = @@ -856,6 +884,7 @@ async fn update_flow( Extension(db): Extension, Extension(webhook): Extension, Path((w_id, flow_path)): Path<(String, StripPath)>, + Query(deployed_from): Query, Json(nf): Json, ) -> Result { if authed.is_operator { @@ -863,8 +892,29 @@ async fn update_flow( "Operators cannot update flows for security reasons".to_string(), )); } + let flow_path = flow_path.to_path(); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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; diff --git a/backend/windmill-api/src/folders.rs b/backend/windmill-api/src/folders.rs index a73eb99b7e..0c94ac37d3 100644 --- a/backend/windmill-api/src/folders.rs +++ b/backend/windmill-api/src/folders.rs @@ -22,13 +22,13 @@ use axum::{ }; use lazy_static::lazy_static; use regex::Regex; -use windmill_audit::audit_oss::audit_log; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::{ db::UserDB, - error::{self, to_anyhow, JsonResult, Result}, + error::{self, to_anyhow, Error, JsonResult, Result}, users::username_to_permissioned_as, - utils::{not_found_if_none, paginate, Pagination}, + utils::{not_found_if_none, paginate, Pagination}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, }; use serde::{Deserialize, Serialize}; @@ -168,6 +168,11 @@ lazy_static! { static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap(); } +#[derive(Deserialize)] +struct DeployedFromQuery { + deployed_from_workspace: Option, +} + async fn create_folder( authed: ApiAuthed, Tokened { token }: Tokened, @@ -176,8 +181,28 @@ async fn create_folder( Extension(webhook): Extension, Extension(cache): Extension>, Path(w_id): Path, + Query(deployed_from): Query, Json(ng): Json, ) -> Result { + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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) { @@ -331,10 +356,30 @@ async fn update_folder( Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, name)): Path<(String, String)>, + Query(deployed_from): Query, Json(mut ng): Json, ) -> Result { use sql_builder::prelude::*; + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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)); diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index 575e09421c..7e4af554bb 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -45,6 +45,7 @@ use windmill_common::{ }, variables, worker::{CLOUD_HOSTED, TMP_DIR}, + workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, PgDatabase, }; @@ -113,6 +114,11 @@ pub struct EditResourceType { pub description: Option, } +#[derive(Deserialize)] +struct DeployedFromQuery { + deployed_from_workspace: Option, +} + #[derive(FromRow, Serialize, Deserialize)] pub struct Resource { pub workspace_id: String, @@ -693,9 +699,30 @@ async fn create_resource( Extension(webhook): Extension, Path(w_id): Path, Query(q): Query, + Query(deployed_from): Query, Json(resource): Json, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || format!("resources:write:{}", resource.path))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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", @@ -923,6 +950,7 @@ async fn update_resource( Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(deployed_from): Query, Json(ns): Json, ) -> Result { use sql_builder::prelude::*; @@ -930,6 +958,25 @@ async fn update_resource( let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); @@ -1051,10 +1098,31 @@ async fn update_resource_value( Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(deployed_from): Query, Json(nv): Json, ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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!( @@ -1204,10 +1272,30 @@ async fn create_resource_type( Extension(user_db): Extension, Extension(webhook): Extension, Path(w_id): Path, + Query(deployed_from): Query, Json(resource_type): Json, ) -> Result<(StatusCode, String)> { let mut tx = user_db.begin(&authed).await?; + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?; sqlx::query!( @@ -1343,10 +1431,30 @@ async fn update_resource_type( Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, name)): Path<(String, String)>, + Query(deployed_from): Query, Json(ns): Json, ) -> Result { use sql_builder::prelude::*; + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index 5032bfe5db..c58b0e201e 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -36,7 +36,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_worker::{process_relative_imports, scoped_dependency_map::ScopedDependencyMap}; @@ -53,7 +53,7 @@ use windmill_common::{ s3_helpers::upload_artifact_to_store, scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline}, utils::{paginate_without_limits, WarnAfterExt}, - worker::CLOUD_HOSTED, + worker::CLOUD_HOSTED, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, }; use windmill_common::{ @@ -543,14 +543,39 @@ async fn list_paths_from_workspace_runnable( Ok(Json(runnables)) } +#[derive(Deserialize)] +struct DeployedFromQuery { + deployed_from_workspace: Option, +} + async fn create_script( authed: ApiAuthed, Extension(user_db): Extension, Extension(webhook): Extension, Extension(db): Extension, Path(w_id): Path, + Query(deployed_from): Query, Json(ns): Json, ) -> Result<(StatusCode, String)> { + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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?; diff --git a/backend/windmill-api/src/variables.rs b/backend/windmill-api/src/variables.rs index 6fd97247d4..e41ba357ad 100644 --- a/backend/windmill-api/src/variables.rs +++ b/backend/windmill-api/src/variables.rs @@ -37,6 +37,7 @@ use windmill_common::{ build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable, }, worker::CLOUD_HOSTED, + workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, }; use crate::var_resource_cache::{cache_variable, get_cached_variable}; @@ -99,6 +100,11 @@ struct ListVariableQuery { path_start: Option, } +#[derive(Deserialize)] +struct DeployedFromQuery { + deployed_from_workspace: Option, +} + async fn list_variables( authed: ApiAuthed, Extension(user_db): Extension, @@ -333,9 +339,30 @@ async fn create_variable( Extension(webhook): Extension, Path(w_id): Path, Query(AlreadyEncrypted { already_encrypted }): Query, + Query(deployed_from): Query, Json(variable): Json, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || format!("variables:write:{}", variable.path))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + 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", @@ -606,12 +633,33 @@ async fn update_variable( Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, Query(AlreadyEncrypted { already_encrypted }): Query, + Query(deployed_from): Query, Json(ns): Json, ) -> Result { use sql_builder::prelude::*; let path = path.to_path(); check_scopes(&authed, || format!("variables:write:{}", path))?; + + let rule_kind = if deployed_from.deployed_from_workspace.is_some() { + ProtectionRuleKind::DisableMergeUIInForks + } else { + ProtectionRuleKind::RequireForkOrBranchToDeploy + }; + + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + &w_id, + &rule_kind, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await; let mut sqlb = SqlBuilder::update_table("variable"); diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 93c524f5fc..6c21a4a5a0 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -30,8 +30,9 @@ use regex::Regex; use hex; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; +use strum::IntoEnumIterator; use uuid::Uuid; -use windmill_audit::audit_oss::audit_log; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::s3_helpers::LargeFileStorage; @@ -43,8 +44,9 @@ 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; @@ -186,7 +188,13 @@ pub fn workspaced_service() -> Router { "/reset_diff_tally/:fork_workspace_id", post(reset_workspace_diffs), ) - .route("/compare/:target_workspace_id", get(compare_workspaces)); + .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), + ); #[cfg(all(feature = "stripe", feature = "enterprise"))] { @@ -3525,6 +3533,19 @@ async fn create_workspace_fork_branch( ))); } + 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)); + } + if *DISABLE_WORKSPACE_FORK { require_super_admin(&db, &authed.email).await?; } @@ -3546,6 +3567,19 @@ async fn create_workspace_fork( ))); } + 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)); + } + if *DISABLE_WORKSPACE_FORK { require_super_admin(&db, &authed.email).await?; } @@ -5190,6 +5224,270 @@ async fn compare_two_variables( }); } +// 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)>, + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Json(req): Json, +) -> Result { + require_admin(is_admin, &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)>, + ApiAuthed { is_admin, username, .. }: ApiAuthed, +) -> Result { + require_admin(is_admin, &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)) +} + async fn compare_two_resource_types( db: &DB, source_workspace_id: &str, diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 4cbb569431..968ed631b7 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -13,6 +13,109 @@ 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 REQUIRE_FORK_OR_BRANCH_TO_DEPLOY = 1 << 0; + const DISABLE_WORKSPACE_FORKING = 1 << 1; + const DISABLE_MERGE_UI_IN_FORKS = 1 << 2; + } +} + +sqlx_bitflags!(ProtectionRules => i32); + +#[derive(Serialize, Deserialize, strum_macros::EnumIter)] +pub enum ProtectionRuleKind { + RequireForkOrBranchToDeploy, + DisableWorkspaceForking, + DisableMergeUIInForks, +} + +impl ProtectionRuleKind { + pub const fn flag(&self) -> ProtectionRules { + match self { + ProtectionRuleKind::RequireForkOrBranchToDeploy => { + ProtectionRules::REQUIRE_FORK_OR_BRANCH_TO_DEPLOY + } + ProtectionRuleKind::DisableWorkspaceForking => { + ProtectionRules::DISABLE_WORKSPACE_FORKING + } + ProtectionRuleKind::DisableMergeUIInForks => ProtectionRules::DISABLE_MERGE_UI_IN_FORKS, + } + } + + pub const fn msg(&self) -> &str { + match self { + ProtectionRuleKind::RequireForkOrBranchToDeploy => { + "Cannot directly deploy in this workspace. Fork or Pull request required." + } + ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden", + ProtectionRuleKind::DisableMergeUIInForks => { + "UI deployment to parent is forbidden. Use a pull request instead" + } + } + } +} + +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 +279,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)) + { + return Ok(RuleCheckResult::Allowed); + } + 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/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 2546868041..c0df8c8196 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -27,6 +27,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' @@ -52,6 +53,7 @@ let mergeIntoParent = $state(true) let deploying = $state(false) let hasAutoSelected = $state(false) + let canDeployToParent = $state(true) let selectableDiffs = $derived( comparison?.diffs.filter((diff) => { @@ -393,6 +395,7 @@ if (alreadyExists) { await FlowService.updateFlow({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, path: path, requestBody: { ...flow @@ -401,6 +404,7 @@ } else { await FlowService.createFlow({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { ...flow } @@ -413,6 +417,7 @@ }) await ScriptService.createScript({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { ...script, lock: script.lock, @@ -447,6 +452,7 @@ }) await AppService.updateAppRaw({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, path: path, formData: { app, @@ -457,6 +463,7 @@ } else { await AppService.updateApp({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, path: path, requestBody: { ...app @@ -479,6 +486,7 @@ }) await AppService.createAppRaw({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, formData: { app, css, @@ -488,6 +496,7 @@ } else { await AppService.createApp({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { ...app } @@ -503,6 +512,7 @@ if (alreadyExists) { await VariableService.updateVariable({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, path: path, requestBody: { path: path, @@ -515,6 +525,7 @@ } else { await VariableService.createVariable({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { path: path, value: variable.value ?? '', @@ -531,6 +542,7 @@ if (alreadyExists) { await ResourceService.updateResource({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, path: path, requestBody: { path: path, @@ -541,6 +553,7 @@ } else { await ResourceService.createResource({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { path: path, value: resource.value ?? '', @@ -557,6 +570,7 @@ if (alreadyExists) { await ResourceService.updateResourceType({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, path: path, requestBody: { schema: resource.schema, @@ -566,6 +580,7 @@ } else { await ResourceService.createResourceType({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { description: resource.description ?? '', schema: resource.schema, @@ -591,6 +606,7 @@ } else if (kind == 'folder') { await FolderService.createFolder({ workspace: workspaceToDeployTo, + deployedFromWorkspace: workspaceFrom, requestBody: { name: path } @@ -819,6 +835,14 @@ + {#if mergeIntoParent} + { + canDeployToParent = canDeploy + }} + /> + {/if} {#if conflictingDiffs.length > 0} @@ -829,7 +853,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 != ''} @@ -1058,6 +1085,4 @@
No comparison data available
{/if} - - diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index c1d1871604..c8e07e914e 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -44,6 +44,7 @@ additionalInformation?: AdditionalInformation | undefined workspaceToDeployTo?: string | undefined hideButton?: boolean + canDeployToWorkspace?: boolean } let { @@ -51,7 +52,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 = @@ -318,6 +320,7 @@ if (alreadyExists) { await FlowService.updateFlow({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { ...flow @@ -326,6 +329,7 @@ } else { await FlowService.createFlow({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { ...flow } @@ -338,6 +342,7 @@ }) await ScriptService.createScript({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { ...script, lock: script.lock, @@ -372,6 +377,7 @@ }) await AppService.updateAppRaw({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, path: path, formData: { app, @@ -382,6 +388,7 @@ } else { await AppService.updateApp({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { ...app @@ -404,6 +411,7 @@ }) await AppService.createAppRaw({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, formData: { app, css, @@ -413,6 +421,7 @@ } else { await AppService.createApp({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { ...app } @@ -428,6 +437,7 @@ if (alreadyExists) { await VariableService.updateVariable({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { path: path, @@ -440,6 +450,7 @@ } else { await VariableService.createVariable({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { path: path, value: variable.value ?? '', @@ -456,6 +467,7 @@ if (alreadyExists) { await ResourceService.updateResource({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { path: path, @@ -466,6 +478,7 @@ } else { await ResourceService.createResource({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { path: path, value: resource.value ?? '', @@ -482,6 +495,7 @@ if (alreadyExists) { await ResourceService.updateResourceType({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { schema: resource.schema, @@ -491,6 +505,7 @@ } else { await ResourceService.createResourceType({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { description: resource.description ?? '', schema: resource.schema, @@ -516,6 +531,7 @@ } else if (kind == 'folder') { await FolderService.createFolder({ workspace: workspaceToDeployTo!, + deployedFromWorkspace: $workspaceStore!, requestBody: { name: path } @@ -771,7 +787,7 @@ > {/if} {:else} - + {/if} {/each} @@ -779,7 +795,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..768a9ca018 --- /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. Use either a fork and the deployment UI, or a git sync based workflow (such as a PR on your synced repo) to make changes. +

+ {#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..733f678724 --- /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 an 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()}