diff --git a/backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json b/backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json deleted file mode 100644 index abe21b0930..0000000000 --- a/backend/.sqlx/query-4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "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 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json b/backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json deleted file mode 100644 index 5badb301e6..0000000000 --- a/backend/.sqlx/query-65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index cf8800d900..0000000000 --- a/backend/.sqlx/query-7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 0bafcb2f18..0000000000 --- a/backend/.sqlx/query-b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index ae5792117d..0000000000 --- a/backend/.sqlx/query-dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 8fc3444e63..0000000000 --- a/backend/migrations/20260114180000_add_protection_rules.down.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 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 deleted file mode 100644 index d2cd011b33..0000000000 --- a/backend/migrations/20260114180000_add_protection_rules.up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 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 ffdc0cecfe..f0672666d0 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:backon", "oauth2"] +native_trigger = ["dep:strum", "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,7 +151,6 @@ 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 @@ -169,6 +168,7 @@ 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 096f834615..ee1a974346 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3780,12 +3780,6 @@ 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 @@ -3889,12 +3883,6 @@ 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 @@ -4167,126 +4155,6 @@ 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 @@ -4747,12 +4615,6 @@ 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 @@ -4828,12 +4690,6 @@ 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 @@ -4858,12 +4714,6 @@ 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 @@ -5111,12 +4961,6 @@ 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 @@ -5173,12 +5017,6 @@ 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 @@ -6084,12 +5922,6 @@ 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 @@ -7715,12 +7547,6 @@ 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 @@ -7752,12 +7578,6 @@ 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 @@ -8073,12 +7893,6 @@ 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 @@ -8121,12 +7935,6 @@ 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 @@ -8515,12 +8323,6 @@ 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 @@ -8557,12 +8359,6 @@ 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 @@ -14227,12 +14023,6 @@ 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 @@ -14271,12 +14061,6 @@ 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 @@ -23222,46 +23006,7 @@ 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 c33826be08..9d2d6d9db3 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, AuditAuthorable}; +use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::{ apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE}, @@ -65,7 +65,6 @@ 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, }; @@ -216,11 +215,6 @@ 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; @@ -1048,7 +1042,6 @@ 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 { @@ -1056,26 +1049,6 @@ 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, @@ -1126,7 +1099,6 @@ 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 { @@ -1137,25 +1109,6 @@ 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?; @@ -1458,7 +1411,7 @@ async fn delete_app( deployed_object, Some(format!("App '{}' deleted", path)), true, - None, + None, ) .await?; @@ -1489,7 +1442,6 @@ 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 { @@ -1500,26 +1452,6 @@ 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?; @@ -1542,7 +1474,6 @@ 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 { @@ -1551,26 +1482,6 @@ 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 47c693277a..fbfd64e2ef 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, AuditAuthorable}; +use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; use windmill_common::min_version::{ @@ -39,7 +39,6 @@ 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, @@ -418,18 +417,12 @@ 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 { @@ -437,28 +430,7 @@ 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 = @@ -884,7 +856,6 @@ 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 { @@ -892,29 +863,8 @@ 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 0c94ac37d3..a73eb99b7e 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, AuditAuthorable}; +use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::{ db::UserDB, - error::{self, to_anyhow, Error, JsonResult, Result}, + error::{self, to_anyhow, JsonResult, Result}, users::username_to_permissioned_as, - utils::{not_found_if_none, paginate, Pagination}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, + utils::{not_found_if_none, paginate, Pagination}, }; use serde::{Deserialize, Serialize}; @@ -168,11 +168,6 @@ 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, @@ -181,28 +176,8 @@ 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) { @@ -356,30 +331,10 @@ 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 7e4af554bb..575e09421c 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -45,7 +45,6 @@ use windmill_common::{ }, variables, worker::{CLOUD_HOSTED, TMP_DIR}, - workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, PgDatabase, }; @@ -114,11 +113,6 @@ 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, @@ -699,30 +693,9 @@ 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", @@ -950,7 +923,6 @@ 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::*; @@ -958,25 +930,6 @@ 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)); @@ -1098,31 +1051,10 @@ 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!( @@ -1272,30 +1204,10 @@ 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!( @@ -1431,30 +1343,10 @@ 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 c58b0e201e..5032bfe5db 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, AuditAuthorable}; +use windmill_audit::audit_oss::audit_log; 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, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, + worker::CLOUD_HOSTED, }; use windmill_common::{ @@ -543,39 +543,14 @@ 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 e41ba357ad..6fd97247d4 100644 --- a/backend/windmill-api/src/variables.rs +++ b/backend/windmill-api/src/variables.rs @@ -37,7 +37,6 @@ 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}; @@ -100,11 +99,6 @@ struct ListVariableQuery { path_start: Option, } -#[derive(Deserialize)] -struct DeployedFromQuery { - deployed_from_workspace: Option, -} - async fn list_variables( authed: ApiAuthed, Extension(user_db): Extension, @@ -339,30 +333,9 @@ 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", @@ -633,33 +606,12 @@ 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 6c21a4a5a0..93c524f5fc 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -30,9 +30,8 @@ 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, AuditAuthorable}; +use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::s3_helpers::LargeFileStorage; @@ -44,9 +43,8 @@ use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ - check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable, - DataTableCatalogResourceType, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, - RuleCheckResult, WorkspaceGitSyncSettings, + get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, + WorkspaceGitSyncSettings, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; @@ -188,13 +186,7 @@ pub fn workspaced_service() -> Router { "/reset_diff_tally/:fork_workspace_id", 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), - ); + .route("/compare/:target_workspace_id", get(compare_workspaces)); #[cfg(all(feature = "stripe", feature = "enterprise"))] { @@ -3533,19 +3525,6 @@ 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?; } @@ -3567,19 +3546,6 @@ 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?; } @@ -5224,270 +5190,6 @@ 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 968ed631b7..4cbb569431 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -13,109 +13,6 @@ 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")] @@ -279,111 +176,6 @@ 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 c0df8c8196..2546868041 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -27,7 +27,6 @@ } 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' @@ -53,7 +52,6 @@ let mergeIntoParent = $state(true) let deploying = $state(false) let hasAutoSelected = $state(false) - let canDeployToParent = $state(true) let selectableDiffs = $derived( comparison?.diffs.filter((diff) => { @@ -395,7 +393,6 @@ if (alreadyExists) { await FlowService.updateFlow({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, path: path, requestBody: { ...flow @@ -404,7 +401,6 @@ } else { await FlowService.createFlow({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { ...flow } @@ -417,7 +413,6 @@ }) await ScriptService.createScript({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { ...script, lock: script.lock, @@ -452,7 +447,6 @@ }) await AppService.updateAppRaw({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, path: path, formData: { app, @@ -463,7 +457,6 @@ } else { await AppService.updateApp({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, path: path, requestBody: { ...app @@ -486,7 +479,6 @@ }) await AppService.createAppRaw({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, formData: { app, css, @@ -496,7 +488,6 @@ } else { await AppService.createApp({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { ...app } @@ -512,7 +503,6 @@ if (alreadyExists) { await VariableService.updateVariable({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, path: path, requestBody: { path: path, @@ -525,7 +515,6 @@ } else { await VariableService.createVariable({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { path: path, value: variable.value ?? '', @@ -542,7 +531,6 @@ if (alreadyExists) { await ResourceService.updateResource({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, path: path, requestBody: { path: path, @@ -553,7 +541,6 @@ } else { await ResourceService.createResource({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { path: path, value: resource.value ?? '', @@ -570,7 +557,6 @@ if (alreadyExists) { await ResourceService.updateResourceType({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, path: path, requestBody: { schema: resource.schema, @@ -580,7 +566,6 @@ } else { await ResourceService.createResourceType({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { description: resource.description ?? '', schema: resource.schema, @@ -606,7 +591,6 @@ } else if (kind == 'folder') { await FolderService.createFolder({ workspace: workspaceToDeployTo, - deployedFromWorkspace: workspaceFrom, requestBody: { name: path } @@ -835,14 +819,6 @@ - {#if mergeIntoParent} - { - canDeployToParent = canDeploy - }} - /> - {/if} {#if conflictingDiffs.length > 0} @@ -853,7 +829,7 @@ {/if} - {#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)} + {#if hasBehindChanges && hasAheadChanges} {#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible} - {#if !(mergeIntoParent && !canDeployToParent)} - - {/if} + {/if} {#if deploymentErrorMessage != ''} @@ -1085,4 +1058,6 @@
No comparison data available
{/if} + + diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index c8e07e914e..c1d1871604 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -44,7 +44,6 @@ additionalInformation?: AdditionalInformation | undefined workspaceToDeployTo?: string | undefined hideButton?: boolean - canDeployToWorkspace?: boolean } let { @@ -52,8 +51,7 @@ initialPath = '', additionalInformation = undefined, workspaceToDeployTo = $bindable(undefined), - hideButton = false, - canDeployToWorkspace = $bindable(false) + hideButton = false }: Props = $props() let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined = @@ -320,7 +318,6 @@ if (alreadyExists) { await FlowService.updateFlow({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { ...flow @@ -329,7 +326,6 @@ } else { await FlowService.createFlow({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { ...flow } @@ -342,7 +338,6 @@ }) await ScriptService.createScript({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { ...script, lock: script.lock, @@ -377,7 +372,6 @@ }) await AppService.updateAppRaw({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, path: path, formData: { app, @@ -388,7 +382,6 @@ } else { await AppService.updateApp({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { ...app @@ -411,7 +404,6 @@ }) await AppService.createAppRaw({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, formData: { app, css, @@ -421,7 +413,6 @@ } else { await AppService.createApp({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { ...app } @@ -437,7 +428,6 @@ if (alreadyExists) { await VariableService.updateVariable({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { path: path, @@ -450,7 +440,6 @@ } else { await VariableService.createVariable({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { path: path, value: variable.value ?? '', @@ -467,7 +456,6 @@ if (alreadyExists) { await ResourceService.updateResource({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { path: path, @@ -478,7 +466,6 @@ } else { await ResourceService.createResource({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { path: path, value: resource.value ?? '', @@ -495,7 +482,6 @@ if (alreadyExists) { await ResourceService.updateResourceType({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, path: path, requestBody: { schema: resource.schema, @@ -505,7 +491,6 @@ } else { await ResourceService.createResourceType({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { description: resource.description ?? '', schema: resource.schema, @@ -531,7 +516,6 @@ } else if (kind == 'folder') { await FolderService.createFolder({ workspace: workspaceToDeployTo!, - deployedFromWorkspace: $workspaceStore!, requestBody: { name: path } @@ -787,7 +771,7 @@ > {/if} {:else} - + {/if} {/each} @@ -795,7 +779,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 ceae074847..6a83d96393 100644 --- a/frontend/src/lib/components/DeployWorkspaceDrawer.svelte +++ b/frontend/src/lib/components/DeployWorkspaceDrawer.svelte @@ -2,16 +2,14 @@ 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 = $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) + 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 export async function openDrawer( initialPath_l: string, @@ -27,14 +25,6 @@ - {#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 deleted file mode 100644 index 768a9ca018..0000000000 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -{#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 deleted file mode 100644 index 733f678724..0000000000 --- a/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -{#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 529b50ea0b..e83a8ae694 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -42,7 +42,6 @@ deleteConfirmedCallback: (() => void) | undefined depth?: number menuOpen?: boolean - showEditButton?: boolean } let { @@ -53,8 +52,7 @@ deploymentDrawer, deleteConfirmedCallback = $bindable(), depth = 0, - menuOpen = $bindable(false), - showEditButton = $bindable(true) + menuOpen = $bindable(false) }: Props = $props() const dispatch = createEventDispatcher() @@ -97,7 +95,7 @@ {/snippet} {#snippet actions()}