Revert "feat: restriction rulesets for workspaces (#7791)" (#7820)

This reverts commit a1cd02d7f8.
This commit is contained in:
Ruben Fiszel
2026-02-05 19:16:40 +01:00
committed by GitHub
parent a1cd02d7f8
commit 309de58134
45 changed files with 214 additions and 2482 deletions
@@ -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"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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;
@@ -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);
+2 -2
View File
@@ -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]
+1 -256
View File
@@ -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:
+2 -91
View File
@@ -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<String>,
}
#[derive(Deserialize)]
struct DeployedFromQuery {
deployed_from_workspace: Option<String>,
}
pub type StaticFields = HashMap<String, Box<RawValue>>;
pub type OneOfFields = HashMap<String, Vec<Box<RawValue>>>;
pub type AllowUserResources = Vec<String>;
@@ -1048,7 +1042,6 @@ async fn create_app_raw<'a>(
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Query(deployed_from): Query<DeployedFromQuery>,
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<DB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(app): Json<CreateApp>,
) -> 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<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(ns): Json<EditApp>,
) -> Result<String> {
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<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Query(deployed_from): Query<DeployedFromQuery>,
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> Result<String> {
@@ -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();
+1 -51
View File
@@ -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<String>,
}
async fn create_flow(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(nf): Json<NewFlow>,
) -> 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<DB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(nf): Json<NewFlow>,
) -> Result<String> {
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;
+3 -48
View File
@@ -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<String>,
}
async fn create_folder(
authed: ApiAuthed,
Tokened { token }: Tokened,
@@ -181,28 +176,8 @@ async fn create_folder(
Extension(webhook): Extension<WebhookShared>,
Extension(cache): Extension<Arc<AuthCache>>,
Path(w_id): Path<String>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(ng): Json<NewFolder>,
) -> Result<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 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<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(mut ng): Json<UpdateFolder>,
) -> Result<String> {
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));
-108
View File
@@ -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<String>,
}
#[derive(Deserialize)]
struct DeployedFromQuery {
deployed_from_workspace: Option<String>,
}
#[derive(FromRow, Serialize, Deserialize)]
pub struct Resource {
pub workspace_id: String,
@@ -699,30 +693,9 @@ async fn create_resource(
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Query(q): Query<CreateResourceQuery>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(resource): Json<CreateResource>,
) -> 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<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(ns): Json<EditResource>,
) -> Result<String> {
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<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(nv): Json<UpdateResource>,
) -> Result<String> {
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<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(resource_type): Json<CreateResourceType>,
) -> 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<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(ns): Json<EditResourceType>,
) -> Result<String> {
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));
+2 -27
View File
@@ -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<String>,
}
async fn create_script(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(ns): Json<NewScript>,
) -> 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?;
-48
View File
@@ -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<String>,
}
#[derive(Deserialize)]
struct DeployedFromQuery {
deployed_from_workspace: Option<String>,
}
async fn list_variables(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -339,30 +333,9 @@ async fn create_variable(
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Query(AlreadyEncrypted { already_encrypted }): Query<AlreadyEncrypted>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(variable): Json<CreateVariable>,
) -> 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<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(AlreadyEncrypted { already_encrypted }): Query<AlreadyEncrypted>,
Query(deployed_from): Query<DeployedFromQuery>,
Json(ns): Json<EditVariable>,
) -> Result<String> {
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");
+4 -302
View File
@@ -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<ProtectionRuleKind>,
bypass_groups: Vec<String>,
bypass_users: Vec<String>,
}
#[derive(Deserialize)]
struct UpdateProtectionRuleRequest {
rules: Vec<ProtectionRuleKind>,
bypass_groups: Vec<String>,
bypass_users: Vec<String>,
}
#[derive(Serialize)]
struct ProtectionRulesetResponse {
pub workspace_id: String,
pub name: String,
pub rules: Vec<ProtectionRuleKind>,
pub bypass_groups: Vec<String>,
pub bypass_users: Vec<String>,
}
impl From<ProtectionRuleset> 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<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<ProtectionRulesetResponse>> {
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<DB>,
Path(w_id): Path<String>,
Json(req): Json<CreateProtectionRuleRequest>,
) -> Result<String> {
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<DB>,
Path((w_id, rule_name)): Path<(String, String)>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(req): Json<UpdateProtectionRuleRequest>,
) -> Result<String> {
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<DB>,
Path((w_id, rule_name)): Path<(String, String)>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
) -> Result<String> {
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,
-208
View File
@@ -13,109 +13,6 @@ use crate::{
PgDatabase, DB,
};
macro_rules! sqlx_bitflags {
(
$flags:ty => $repr:ty
) => {
// ---- Type ----
impl sqlx::Type<sqlx::Postgres> for $flags {
fn type_info() -> sqlx::postgres::PgTypeInfo {
<$repr as sqlx::Type<sqlx::Postgres>>::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<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>>
{
let bits: $repr = self.bits();
<$repr as sqlx::Encode<sqlx::Postgres>>::encode(bits, buf)
}
}
// ---- Decode ----
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $flags {
fn decode(
value: sqlx::postgres::PgValueRef<'r>,
) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let bits = <$repr as sqlx::Decode<sqlx::Postgres>>::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<String>,
pub bypass_users: Vec<String>,
}
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<ProtectionRuleKind>> for ProtectionRules {
fn from(value: &Vec<ProtectionRuleKind>) -> 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<TeamPl
Ok(team_plan_info)
}
// Protection Rules Cache
lazy_static::lazy_static! {
pub static ref PROTECTION_RULES_CACHE: Cache<String, (std::sync::Arc<Vec<ProtectionRuleset>>, 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<std::sync::Arc<Vec<ProtectionRuleset>>> {
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/<username>)
/// - User's group is in the rule's bypass groups list (g/<groupname>)
///
/// 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<RuleCheckResult> {
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,
@@ -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 @@
</div>
</div>
{#if mergeIntoParent}
<ParentWorkspaceProtectionAlert
{parentWorkspaceId}
onUpdateCanDeploy={(canDeploy) => {
canDeployToParent = canDeploy
}}
/>
{/if}
{#if conflictingDiffs.length > 0}
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
<!-- <AlertTriangle class="w-4 h-4" /> -->
@@ -853,7 +829,7 @@
</span>
</Alert>
{/if}
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
{#if hasBehindChanges && hasAheadChanges}
<Alert
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
type="warning"
@@ -1046,23 +1022,20 @@
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
{#if !(mergeIntoParent && !canDeployToParent)}
<Button
color="blue"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
(mergeIntoParent && !canDeployToParent)}
loading={deploying}
on:click={deployChanges}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
{/if}
</Button>
{/if}
<Button
color="blue"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride)}
loading={deploying}
on:click={deployChanges}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
{/if}
</Button>
{/if}
{#if deploymentErrorMessage != ''}
@@ -1085,4 +1058,6 @@
<div class="text-gray-500">No comparison data available</div>
</div>
{/if}
<!-- <DeployWorkspaceItems kind="script" initialPath="u/admin/economical_script" workspaceToDeployTo={parentWorkspaceId} /> -->
</div>
@@ -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}
<Button color="light" size="xs" disabled={!canDeployToWorkspace} on:click={() => deploy(kind, path)}>Deploy</Button>
<Button color="light" size="xs" on:click={() => deploy(kind, path)}>Deploy</Button>
{/if}
</div>
{/each}
@@ -795,7 +779,7 @@
{#if !hideButton}
<div class="mt-16 flex flex-row-reverse max-w-3xl"
><Button on:click={deployAll} disabled={!canDeployToWorkspace}>Deploy all toggled</Button></div
><Button on:click={deployAll}>Deploy all toggled</Button></div
>
{/if}
{:else if canSeeTarget == 'cant-see-all-deps'}
@@ -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 @@
<Drawer bind:this={drawer} size="900px">
<DrawerContent title="Deploy {initialPath}" on:close={drawer.closeDrawer}>
{#if workspaceToDeployTo}
<ParentWorkspaceProtectionAlert
parentWorkspaceId={workspaceToDeployTo}
onUpdateCanDeploy={(canDeploy) => {
canDeployToWorkspace = canDeploy
}}
/>
{/if}
{#if (kind != 'trigger' && kind != undefined && initialPath != undefined) || (kind === 'trigger' && initialPath != undefined && additionalInformation?.triggers != undefined)}
<DeployWorkspace
hideButton
@@ -43,13 +33,12 @@
{additionalInformation}
bind:workspaceToDeployTo
bind:this={deployWorkspace}
bind:canDeployToWorkspace
/>
{/if}
{#snippet actions()}
<Button
disabled={workspaceToDeployTo == undefined || !canDeployToWorkspace}
disabled={workspaceToDeployTo == undefined}
on:click={() => deployWorkspace?.deployAll()}>Deploy All</Button
>
{/snippet}
@@ -1,45 +0,0 @@
<script lang="ts">
import { userStore } from '$lib/stores'
import {
canUserBypassRuleKind,
getActiveRulesetsForKind,
isRuleActive
} from '$lib/workspaceProtectionRules.svelte'
import { Alert } from './common'
let activeDeployRulesets = $derived(getActiveRulesetsForKind('RequireForkOrBranchToDeploy'))
let canBypass = $derived(canUserBypassRuleKind('RequireForkOrBranchToDeploy', $userStore))
let overrideChecked = $state(false)
let canEdit = $derived(
!isRuleActive('RequireForkOrBranchToDeploy') || (canBypass && overrideChecked)
)
let {
onUpdateCanEditStatus = (value) => {}
}: {
onUpdateCanEditStatus?: (value: boolean) => void
} = $props()
$effect(() => {
onUpdateCanEditStatus(canEdit)
})
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
<div class="my-2">
<Alert type="info" title="Workspace protection active">
<div class="flex flex-col gap-2">
<p>
The rule{activeDeployRulesets.length > 1 ? "s" : ""} <b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b> 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.
</p>
{#if canBypass}
<label class="flex items-center gap-2 cursor-pointer">
<input class="rounded max-w-4" type="checkbox" bind:checked={overrideChecked} />
<span class="text-xs">Bypass restriction</span>
</label>
{/if}
</div>
</Alert>
</div>
{/if}
@@ -1,75 +0,0 @@
<script lang="ts">
import type { ProtectionRuleset } from '$lib/gen'
import { userStore } from '$lib/stores'
import {
fetchProtectionRulesForWorkspace,
isRuleActiveInRulesets,
canUserBypassRuleKindInRulesets,
getActiveRulesetsForKindInRulesets,
} from '$lib/workspaceProtectionRules.svelte'
import { Alert } from './common'
import { untrack } from 'svelte'
let {
parentWorkspaceId,
onUpdateCanDeploy = (value) => {}
}: {
parentWorkspaceId: string
onUpdateCanDeploy?: (value: boolean) => void
} = $props()
let parentRulesets = $state<ProtectionRuleset[]>([])
let overrideChecked = $state(false)
let activeDeployRulesets = $derived(
getActiveRulesetsForKindInRulesets(parentRulesets, 'DisableMergeUIInForks')
)
let canBypass = $derived(
canUserBypassRuleKindInRulesets(parentRulesets, 'DisableMergeUIInForks', $userStore)
)
let canDeploy = $derived(
!isRuleActiveInRulesets(parentRulesets, 'DisableMergeUIInForks') ||
(canBypass && overrideChecked)
)
// Fetch parent workspace rules
$effect(() => {
if (parentWorkspaceId) {
untrack(async () => {
const rules = await fetchProtectionRulesForWorkspace(parentWorkspaceId)
parentRulesets = rules
})
}
})
// Reset override when parent workspace changes
$effect(() => {
parentWorkspaceId
overrideChecked = false
})
// Communicate deployment status to parent
$effect(() => {
onUpdateCanDeploy(canDeploy)
})
</script>
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
<Alert type="info" title="Parent workspace protection active" class="my-2">
<div class="flex flex-col gap-2">
<p>
The workspace {parentWorkspaceId} has a protection rule{activeDeployRulesets.length > 1 ? 's' : ''}
<b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b>
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.
</p>
{#if canBypass}
<label class="flex items-center gap-2 cursor-pointer">
<input class="rounded max-w-4" type="checkbox" bind:checked={overrideChecked} />
<span class="text-xs">Bypass restriction and deploy anyway</span>
</label>
{/if}
</div>
</Alert>
{/if}
@@ -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()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator && showEditButton}
{#if !$userStore?.operator}
{#if app.canWrite}
<div>
<Button
@@ -134,7 +132,6 @@
items={async () => {
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
const canEdit = canWrite && showEditButton
if (draft_only) {
return [
{
@@ -154,7 +151,7 @@
}
},
type: 'delete',
disabled: !canEdit,
disabled: !canWrite,
hide: $userStore?.operator
},
{
@@ -171,7 +168,6 @@
displayName: 'Duplicate/Fork',
icon: GitFork,
href: `${base}/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`,
disabled: !showEditButton,
hide: $userStore?.operator
},
{
@@ -180,7 +176,7 @@
action: () => {
moveDrawer.openDrawer(path, summary, 'app')
},
disabled: !canEdit,
disabled: !canWrite,
hide: $userStore?.operator
},
...(isDeployable('app', path, await getDeployUiSettings())
@@ -278,7 +274,7 @@
}
},
type: 'delete',
disabled: !canEdit,
disabled: !canWrite,
hide: $userStore?.operator
}
]
@@ -45,7 +45,6 @@
errorHandlerMuted: boolean
depth?: number
menuOpen?: boolean
showEditButton?: boolean
}
let {
@@ -57,8 +56,7 @@
deploymentDrawer,
errorHandlerMuted,
depth = 0,
menuOpen = $bindable(false),
showEditButton = $bindable(true)
menuOpen = $bindable(false)
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -120,7 +118,7 @@
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator && showEditButton}
{#if !$userStore?.operator}
{#if flow.canWrite && !flow.archived}
<div>
<Button
@@ -159,7 +157,6 @@
items={async () => {
let { draft_only, path, archived, has_draft } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
return [
{
@@ -182,18 +179,17 @@
]
}
return [
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/${path}`
},
{
displayName: 'Duplicate/Fork',
icon: GitFork,
href: `${base}/flows/add?template=${path}`,
disabled: !showEditButton,
hide: $userStore?.operator
},
{
displayName: 'View runs',
icon: List,
href: `${base}/runs/${path}`
},
{
displayName: 'Audit logs',
icon: Eye,
@@ -206,7 +202,7 @@
action: () => {
moveDrawer.openDrawer(path, flow.summary, 'flow')
},
disabled: !owner || archived || !canEdit,
disabled: !owner || archived,
hide: $userStore?.operator
},
{
@@ -261,7 +257,7 @@
path && archiveFlow(path, !archived)
},
type: 'delete',
disabled: !owner || !canEdit,
disabled: !owner,
hide: $userStore?.operator
},
...(has_draft
@@ -297,7 +293,7 @@
}
},
type: 'delete',
disabled: !owner || !canEdit,
disabled: !owner,
hide: $userStore?.operator
}
]
@@ -55,7 +55,6 @@
showCode: (path: string, summary: string) => void
depth?: number
menuOpen?: boolean
showEditButton?: boolean
}
let {
@@ -68,8 +67,7 @@
errorHandlerMuted,
showCode,
depth = 0,
menuOpen = $bindable(false),
showEditButton = $bindable(true)
menuOpen = $bindable(false)
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -150,7 +148,7 @@
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator && showEditButton}
{#if !$userStore?.operator}
{#if script.use_codebase}
<Badge
>bundle<Tooltip
@@ -193,7 +191,6 @@
aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`}
items={async () => {
let owner = isOwner(script.path, $userStore, $workspaceStore)
const canEdit = script.canWrite && showEditButton
if (script.draft_only) {
return [
{
@@ -218,7 +215,7 @@
}
},
type: dlt,
disabled: !canEdit
disabled: !script.canWrite
}
]
}
@@ -234,7 +231,6 @@
displayName: 'Duplicate/Fork',
icon: GitFork,
href: `${base}/scripts/add?template=${script.path}`,
disabled: !showEditButton,
hide: $userStore?.operator
},
{
@@ -243,7 +239,7 @@
action: () => {
moveDrawer.openDrawer(script.path, script.summary, 'script')
},
disabled: !owner || script.archived || !canEdit,
disabled: !owner || script.archived,
hide: $userStore?.operator
},
...(isDeployable('script', script.path, await getDeployUiSettings())
@@ -334,7 +330,7 @@
: script.path && archiveScript(script.path)
},
type: 'delete',
disabled: !owner || !canEdit,
disabled: !owner,
hide: $userStore?.operator
},
@@ -372,7 +368,7 @@
}
},
type: dlt,
disabled: !canEdit,
disabled: !script.canWrite,
hide: $userStore?.operator
}
]
@@ -9,6 +9,7 @@
import { LayoutDashboard, Loader2, Plus, Code2, FlaskConical } from 'lucide-svelte'
import { importStore } from '../apps/store'
import Badge from '$lib/components/common/badge/Badge.svelte'
import YAML from 'yaml'
let drawer: Drawer | undefined = undefined
@@ -48,35 +49,35 @@
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-app-button"
aiId="apps-create-actions-app"
aiDescription="Create a new app"
unifiedSize="lg"
startIcon={{ icon: Plus }}
endIcon={{ icon: LayoutDashboard }}
on:click={openAppTypeModal}
variant="accent"
dropdownItems={[
{
label: 'Import low-code app from YAML',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'yaml'
}
},
{
label: 'Import low-code app from JSON',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'json'
}
<Button
id="create-app-button"
aiId="apps-create-actions-app"
aiDescription="Create a new app"
unifiedSize="lg"
startIcon={{ icon: Plus }}
endIcon={{ icon: LayoutDashboard }}
on:click={openAppTypeModal}
variant="accent"
dropdownItems={[
{
label: 'Import low-code app from YAML',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'yaml'
}
]}
>
<div class="flex flex-row items-center"> App </div>
</Button>
</div>
},
{
label: 'Import low-code app from JSON',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'json'
}
}
]}
>
<div class="flex flex-row items-center"> App </div>
</Button>
</div>
<!-- App Type Selection Modal -->
<Modal bind:open={appTypeModalOpen} title="Choose your app builder">
@@ -9,6 +9,7 @@
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
import { Loader2, Plus } from 'lucide-svelte'
import YAML from 'yaml'
let drawer: Drawer | undefined = $state(undefined)
let pendingRaw: string | undefined = $state(undefined)
let importType: 'yaml' | 'json' = $state('yaml')
@@ -23,35 +24,35 @@
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-flow-button"
aiId="flows-create-actions-flow"
aiDescription="Create a new flow"
unifiedSize="lg"
startIcon={{ icon: Plus }}
endIcon={{ icon: BarsStaggered }}
href="{base}/flows/add?nodraft=true"
variant="accent"
dropdownItems={[
{
label: 'Import from YAML',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'yaml'
}
},
{
label: 'Import from JSON',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'json'
}
<Button
id="create-flow-button"
aiId="flows-create-actions-flow"
aiDescription="Create a new flow"
unifiedSize="lg"
startIcon={{ icon: Plus }}
endIcon={{ icon: BarsStaggered }}
href="{base}/flows/add?nodraft=true"
variant="accent"
dropdownItems={[
{
label: 'Import from YAML',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'yaml'
}
]}
>
Flow
</Button>
</div>
},
{
label: 'Import from JSON',
onClick: () => {
drawer?.toggleDrawer?.()
importType = 'json'
}
}
]}
>
Flow
</Button>
</div>
<!-- Raw JSON -->
<Drawer bind:this={drawer} size="800px">
@@ -23,7 +23,6 @@
let menuOpen: boolean = false
export let showCode: (path: string, summary: string) => void
export let showEditButton: boolean = true
</script>
{#if item.type == 'script'}
@@ -42,7 +41,6 @@
{depth}
bind:menuOpen
{showCode}
{showEditButton}
/>
{:else if item.type == 'flow'}
<FlowRow
@@ -59,7 +57,6 @@
{deploymentDrawer}
{depth}
bind:menuOpen
{showEditButton}
/>
{:else if item.type == 'app'}
<AppRow
@@ -72,7 +69,6 @@
{deploymentDrawer}
{depth}
bind:menuOpen
{showEditButton}
/>
{:else if item.type == 'raw_app'}
<RawAppRow
@@ -46,10 +46,9 @@
interface Props {
filter?: string
subtab?: 'flow' | 'script' | 'app'
showEditButtons?: boolean
}
let { filter = $bindable(''), subtab = $bindable('script'), showEditButtons = true }: Props = $props()
let { filter = $bindable(''), subtab = $bindable('script') }: Props = $props()
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
@@ -564,7 +563,6 @@
loadRawApps()
}}
{showCode}
showEditButton={showEditButtons}
/>
{/each}
</div>
@@ -6,6 +6,7 @@
let { aiId, aiDescription }: { aiId: string; aiDescription: string } = $props()
</script>
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-script-button"
@@ -180,11 +180,6 @@
inputClass ?? ''
)}
autocomplete="off"
oninput={(e) => {
// Explicitly open dropdown if closed and update filterText
if (!open) open = true
filterText = e.currentTarget.value
}}
onpointerdown={() => (open = true)}
bind:this={inputEl}
{id}
@@ -9,7 +9,6 @@
workspaceColor,
clearWorkspaceFromStorage
} from '$lib/stores'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { Menu, MenuItem } from '$lib/components/meltComponents'
@@ -178,7 +177,7 @@
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')}
{#if !strictWorkspaceSelect && !isCloudHosted()}
<div class="py-1" role="none">
<MenuItem href="{base}/user/fork_workspace" class={itemClass} {item}>
<GitFork size={16} />
@@ -1,330 +0,0 @@
<script lang="ts">
import { Section, Button, Badge } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import Label from '$lib/components/Label.svelte'
import Select from '$lib/components/select/Select.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { workspaceStore } from '$lib/stores'
import { GroupService, UserService, WorkspaceService, type ProtectionRuleKind, type ProtectionRuleset } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { clone } from '$lib/utils'
import { untrack } from 'svelte'
import { Save, X, Plus } from 'lucide-svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
interface Props {
rule?: ProtectionRuleset
existingNames?: string[]
onUpdate?: () => void
}
let { rule, existingNames = [], onUpdate }: Props = $props()
// Create mode vs Edit mode
const isCreateMode = $derived(!rule)
// Helper function to check if a rule is in the array
const hasRule = (ruleKind: string) => rule?.rules?.includes(ruleKind as any) ?? false
// Editable state
let name = $state(rule?.name ?? '')
let requireForkOrBranch = $state(hasRule('RequireForkOrBranchToDeploy'))
let disableFork = $state(hasRule('DisableWorkspaceForking'))
let disableMergeUI = $state(hasRule('DisableMergeUIInForks'))
let selectedGroups = $state<string[]>(rule?.bypass_groups?.map((g) => g.replace('g/', '')) ?? [])
let selectedUsers = $state<string[]>(rule?.bypass_users?.map((u) => u.replace('u/', '')) ?? [])
// Initial state for unsaved changes tracking
let initialName = $state(rule?.name ?? '')
let initialRequireForkOrBranch = $state(hasRule('RequireForkOrBranchToDeploy'))
let initialDisableFork = $state(hasRule('DisableWorkspaceForking'))
let initialDisableMergeUI = $state(hasRule('DisableMergeUIInForks'))
let initialSelectedGroups = $state<string[]>(
rule?.bypass_groups ? rule.bypass_groups.map((g) => g.replace('g/', '')) : []
)
let initialSelectedUsers = $state<string[]>(
rule?.bypass_users ? rule.bypass_users.map((u) => u.replace('u/', '')) : []
)
// Available options
let availableGroups = $state<string[]>([])
let availableUsers = $state<string[]>([])
// Temporary values for Select dropdowns
let selectedGroupToAdd = $state<string | undefined>(undefined)
let selectedUserToAdd = $state<string | undefined>(undefined)
// Load available groups and users
async function loadAvailableGroups() {
const groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
availableGroups = groups
}
async function loadAvailableUsers() {
const users = await UserService.listUsernames({ workspace: $workspaceStore! })
availableUsers = users
}
$effect(() => {
if ($workspaceStore) {
untrack(() => {
loadAvailableGroups()
loadAvailableUsers()
})
}
})
// Effect to add selected group
$effect(() => {
if (selectedGroupToAdd && !selectedGroups.includes(selectedGroupToAdd)) {
selectedGroups = [...selectedGroups, selectedGroupToAdd]
untrack(() => {
selectedGroupToAdd = undefined // Reset for next selection
})
}
})
// Effect to add selected user
$effect(() => {
if (selectedUserToAdd && !selectedUsers.includes(selectedUserToAdd)) {
selectedUsers = [...selectedUsers, selectedUserToAdd]
untrack(() => {
selectedUserToAdd = undefined // Reset for next selection
})
}
})
// Computed properties
const hasUnsavedChanges = $derived(
isCreateMode
? name.trim() !== '' ||
requireForkOrBranch ||
disableFork ||
disableMergeUI ||
selectedGroups.length > 0 ||
selectedUsers.length > 0
: name !== initialName ||
requireForkOrBranch !== initialRequireForkOrBranch ||
disableFork !== initialDisableFork ||
disableMergeUI !== initialDisableMergeUI ||
JSON.stringify([...selectedGroups].sort()) !==
JSON.stringify([...initialSelectedGroups].sort()) ||
JSON.stringify([...selectedUsers].sort()) !== JSON.stringify([...initialSelectedUsers].sort())
)
const nameError = $derived.by(() => {
if (!name.trim()) return 'Name is required'
if (isCreateMode) {
if (existingNames.includes(name)) return 'Name already exists'
} else {
if (name !== initialName && existingNames.includes(name)) return 'Name already exists'
}
return undefined
})
const canSave = $derived(!nameError && hasUnsavedChanges)
function removeGroup(group: string) {
selectedGroups = selectedGroups.filter((g) => g !== group)
}
function removeUser(user: string) {
selectedUsers = selectedUsers.filter((u) => u !== user)
}
async function create() {
if (!canSave || !$workspaceStore) return
try {
await WorkspaceService.createProtectionRule({
workspace: $workspaceStore,
requestBody: {
name,
rules: [
...(requireForkOrBranch ? ['RequireForkOrBranchToDeploy' as ProtectionRuleKind] : []),
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
...(disableMergeUI ? ['DisableMergeUIInForks' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
}
})
sendUserToast('Protection rule created successfully')
onUpdate?.()
} catch (error) {
console.error('Failed to create protection rule:', error)
sendUserToast('Failed to create protection rule', true)
}
}
async function save() {
if (!canSave || !$workspaceStore) return
try {
await WorkspaceService.updateProtectionRule({
workspace: $workspaceStore,
ruleName: initialName,
requestBody: {
rules: [
...(requireForkOrBranch ? ['RequireForkOrBranchToDeploy' as ProtectionRuleKind] : []),
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
...(disableMergeUI ? ['DisableMergeUIInForks' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
}
})
sendUserToast('Protection rule saved successfully')
// Update initial state
initialName = name
initialRequireForkOrBranch = requireForkOrBranch
initialDisableFork = disableFork
initialDisableMergeUI = disableMergeUI
initialSelectedGroups = clone(selectedGroups)
initialSelectedUsers = clone(selectedUsers)
onUpdate?.()
} catch (error) {
console.error('Failed to save protection rule:', error)
sendUserToast('Failed to save protection rule', true)
}
}
</script>
<div class="flex flex-col gap-6 p-4">
<!-- Name Section -->
<span class="text-secondary text-sm">
Keep in mind that rulesets can take up to one minute to take effect
</span>
<Section label="Rule Name" class="space-y-2">
<TextInput
size="md"
bind:value={name}
error={nameError}
inputProps={{
placeholder: 'Enter rule name'
}}
/>
{#if nameError}
<div class="text-xs text-red-600">{nameError}</div>
{/if}
</Section>
<!-- Bypass Permissions Section -->
<Section
label="Bypass Permissions"
description="Select the groups and/or users who can bypass the restrictions defined in this rule. These users will be exempt from the rules configured below."
class="space-y-4"
>
<!-- Groups -->
<div class="flex flex-col gap-2">
<Label class="text-xs">Groups</Label>
<Select
bind:value={selectedGroupToAdd}
items={safeSelectItems(availableGroups.filter((g) => !selectedGroups.includes(g)))}
placeholder="Select groups..."
/>
{#if selectedGroups.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each selectedGroups as group (group)}
<Badge color="blue" class="flex items-center gap-1">
{group}
<button type="button" onclick={() => removeGroup(group)} class="ml-1 hover:text-red-600">
<X size={14} />
</button>
</Badge>
{/each}
</div>
{/if}
</div>
<!-- Users -->
<div class="flex flex-col gap-2">
<Label class="text-xs">Users</Label>
<Select
bind:value={selectedUserToAdd}
items={safeSelectItems(availableUsers.filter((u) => !selectedUsers.includes(u)))}
placeholder="Select users..."
/>
{#if selectedUsers.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each selectedUsers as user (user)}
<Badge color="indigo" class="flex items-center gap-1">
{user}
<button type="button" onclick={() => removeUser(user)} class="ml-1 hover:text-red-600">
<X size={14} />
</button>
</Badge>
{/each}
</div>
{/if}
</div>
</Section>
<!-- Protection Rules Section -->
<Section
label="Protection Rules"
description="Configure the rules that will be enforced"
class="space-y-4"
>
<div class="flex flex-col gap-4">
<!-- Require Fork or Branch -->
<div class="flex flex-col gap-2">
<Toggle
bind:checked={requireForkOrBranch}
options={{
right: 'Require fork or git branch for changes'
}}
/>
<div class="text-xs text-secondary ml-6">
Users must use a fork or git branch to make changes. Direct edits are not allowed.
</div>
</div>
<!-- Disable Fork -->
<div class="flex flex-col gap-2">
<Toggle
bind:checked={disableFork}
options={{
right: 'Disable workspace forking'
}}
/>
<div class="text-xs text-secondary ml-6">Users cannot create forks of this workspace.</div>
</div>
<!-- Disable Merge UI -->
<div class="flex flex-col gap-2">
<Toggle
bind:checked={disableMergeUI}
options={{
right: 'Disable merge UI for forks'
}}
/>
<div class="text-xs text-secondary ml-6">
Users cannot deploy fork changes through the web UI. Merges must be done through external
processes such as a PR on the Git Sync repo.
</div>
</div>
</div>
</Section>
<!-- Actions -->
<div class="flex items-center gap-4 pt-4 border-t">
<Button
variant="accent"
unifiedSize="md"
disabled={!canSave}
on:click={isCreateMode ? create : save}
startIcon={{ icon: isCreateMode ? Plus : Save }}
>
{isCreateMode ? 'Create Rule' : 'Save Rule'}
</Button>
{#if hasUnsavedChanges && !isCreateMode}
<span class="text-xs text-secondary">You have unsaved changes</span>
{/if}
</div>
</div>
@@ -1,198 +0,0 @@
<script lang="ts">
import { Alert, Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import Row from '$lib/components/table/Row.svelte'
import Description from '$lib/components/Description.svelte'
import RulesetEditor from './RulesetEditor.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Plus, Pen, Trash } from 'lucide-svelte'
import { untrack } from 'svelte'
import { WorkspaceService, type ProtectionRuleset } from '$lib/gen'
let rules: ProtectionRuleset[] | undefined = $state<ProtectionRuleset[] | undefined>(undefined)
let selectedRule: ProtectionRuleset | undefined = $state(undefined)
let ruleDrawer: Drawer | undefined = $state(undefined)
async function loadRules() {
if (!$workspaceStore) return
try {
rules = await WorkspaceService.listProtectionRules({ workspace: $workspaceStore })
} catch (error) {
console.error('Failed to load protection rules:', error)
sendUserToast('Failed to load protection rules', true)
rules = []
}
}
$effect(() => {
if ($workspaceStore) {
untrack(() => loadRules())
}
})
async function deleteRule(name: string) {
if (!$workspaceStore) return
try {
await WorkspaceService.deleteProtectionRule({
workspace: $workspaceStore,
ruleName: name
})
await loadRules()
sendUserToast('Protection rule deleted')
} catch (error) {
console.error('Failed to delete protection rule:', error)
sendUserToast('Failed to delete protection rule', true)
}
}
function getScopeSummary(bypassGroups: string[], bypassUsers: string[]): string {
const groupCount = bypassGroups.length
const userCount = bypassUsers.length
const parts: string[] = []
if (groupCount > 0) parts.push(`${groupCount} group${groupCount !== 1 ? 's' : ''}`)
if (userCount > 0) parts.push(`${userCount} user${userCount !== 1 ? 's' : ''}`)
return parts.length > 0 ? `${parts.join(', ')} can bypass` : 'No bypassers'
}
function getEnabledRulesCount(ruleConfig: ProtectionRuleset['rules']): number {
return ruleConfig.length
}
const existingRuleNames = $derived(
rules?.filter((r) => r.name !== selectedRule?.name).map((r) => r.name) ?? []
)
</script>
<Drawer bind:this={ruleDrawer}>
<DrawerContent
title={selectedRule ? `Protection Rule: ${selectedRule.name}` : 'New Protection Rule'}
on:close={ruleDrawer?.closeDrawer}
>
<RulesetEditor
rule={selectedRule}
existingNames={existingRuleNames}
onUpdate={() => {
loadRules()
ruleDrawer?.closeDrawer()
}}
/>
</DrawerContent>
</Drawer>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis">Workspace Protection Rules</div>
<Description>
Create and manage protection rules that define restrictions and specify which groups and
users can bypass those restrictions. Users not in any bypass list will be subject to the
configured rules.
</Description>
</div>
</div>
{#if !$enterpriseLicense}
<Alert type="warning" title="Workspace Protection Rules is an EE feature">
Workspace Protection Rules is a Windmill Enterprise Edition feature. It enables granular
governance and security policies scoped to specific groups and users.
</Alert>
<div class="pb-4"></div>
{/if}
<div class="flex flex-row justify-between items-center mb-4">
<div class="text-xs font-semibold text-emphasis">Protection Rules</div>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => {
selectedRule = undefined
ruleDrawer?.openDrawer()
}}
>
New rule
</Button>
</div>
<div class="relative mb-20">
<DataTable>
<Head>
<tr>
<Cell head first>Name</Cell>
<Cell head>Bypassers</Cell>
<Cell head>Rules</Cell>
<Cell head last />
</tr>
</Head>
<tbody class="divide-y">
{#if rules === undefined}
{#each new Array(3) as _}
<tr>
<td colspan="4">
<Skeleton layout={[[2]]} />
</td>
</tr>
{/each}
{:else if rules.length === 0}
<tr>
<Cell first last colspan={4}>
<div class="text-center py-8 text-secondary text-sm">
No protection rules created yet. Click "New rule" to create your first rule.
</div>
</Cell>
</tr>
{:else}
{#each rules as rule (rule.name)}
<Row
hoverable
on:click={() => {
selectedRule = rule
ruleDrawer?.openDrawer()
}}
>
<Cell first>
<div class="flex flex-col">
<span class="text-emphasis text-xs font-semibold">{rule.name}</span>
</div>
</Cell>
<Cell>
<span class="text-xs text-secondary">{getScopeSummary(rule.bypass_groups, rule.bypass_users)}</span>
</Cell>
<Cell>
<span class="text-xs text-secondary">
{getEnabledRulesCount(rule.rules)} enabled
</span>
</Cell>
<Cell last>
<Dropdown
items={[
{
displayName: 'Edit rule',
icon: Pen,
action: (e) => {
e?.stopPropagation()
selectedRule = rule
ruleDrawer?.openDrawer()
}
},
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: async () => {
await deleteRule(rule.name)
}
}
]}
/>
</Cell>
</Row>
{/each}
{/if}
</tbody>
</DataTable>
</div>
-11
View File
@@ -8,7 +8,6 @@ import {
devopsRole,
clearWorkspaceFromStorage
} from './stores'
import { resetProtectionRules, loadProtectionRules } from './workspaceProtectionRules.svelte'
export function switchWorkspace(workspace: string | undefined) {
try {
@@ -18,16 +17,7 @@ export function switchWorkspace(workspace: string | undefined) {
console.error('error interacting with local storage', e)
}
resourceTypesStore.set(undefined)
// Clear protection rules state
resetProtectionRules()
workspaceStore.set(workspace)
// Eagerly load protection rules for new workspace
if (workspace) {
loadProtectionRules(workspace)
}
}
export function clearStores(): void {
@@ -40,7 +30,6 @@ export function clearStores(): void {
}
resourceTypesStore.set(undefined)
resetProtectionRules()
userStore.set(undefined)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
@@ -1,219 +0,0 @@
import { WorkspaceService, type ProtectionRuleset, type ProtectionRuleKind } from './gen'
import type { UserExt } from './stores'
/**
* Internal reactive state using Svelte 5 $state rune
*/
let state = $state<{
rulesets: ProtectionRuleset[] | undefined
loading: boolean
error: string | undefined
workspace: string | undefined
}>({
rulesets: undefined,
loading: false,
error: undefined,
workspace: undefined
})
/**
* Exported reactive state object with readonly getters
*/
export const protectionRulesState = {
get rulesets() {
return state.rulesets
},
get loading() {
return state.loading
},
get error() {
return state.error
},
get workspace() {
return state.workspace
}
}
/**
* Internal function to reset state (used by storeUtils)
*/
export function resetProtectionRules() {
state.rulesets = undefined
state.loading = false
state.error = undefined
state.workspace = undefined
}
/**
* Loads protection rules for a workspace from the API and updates the state
* Early returns if already loading the same workspace to prevent duplicate requests
*/
export async function loadProtectionRules(workspace: string): Promise<void> {
// Early return if already loading for this workspace
if (state.loading && state.workspace === workspace) {
return
}
state.loading = true
state.workspace = workspace
try {
const rulesets = await WorkspaceService.listProtectionRules({ workspace })
state.rulesets = rulesets
state.loading = false
state.error = undefined
} catch (error) {
console.error('Failed to load protection rulesets:', error)
// Fail open: set empty array to allow operations
state.rulesets = []
state.loading = false
state.error = error instanceof Error ? error.message : 'Unknown error'
}
}
/**
* Fetches protection rules for a specific workspace without updating the state
* @param workspace The workspace ID to fetch rules for
* @returns Array of protection rulesets, or empty array on error
*/
export async function fetchProtectionRulesForWorkspace(
workspace: string
): Promise<ProtectionRuleset[]> {
try {
const rulesets = await WorkspaceService.listProtectionRules({ workspace })
return rulesets
} catch (error) {
console.error(`Failed to fetch protection rules for workspace ${workspace}:`, error)
return []
}
}
/**
* Checks if a user can bypass a specific ruleset
* @param ruleset The protection ruleset to check
* @param userInfo The user information
* @returns true if user can bypass (is admin, in bypass_users, or has group in bypass_groups)
*/
export function canUserBypassRule(ruleset: ProtectionRuleset, userInfo: UserExt): boolean {
// Admin always bypasses
if (userInfo.is_admin) {
return true
}
if (ruleset.bypass_users.includes(userInfo.username)) {
return true
}
if (ruleset.bypass_groups.some((bg) => userInfo.groups.includes(bg))) {
return true
}
return false
}
/**
* Checks if a specific rule type is active in ANY ruleset
* FIXED: No longer uses await without async context
* @param ruleKind The rule type to check
* @returns true if the rule is active in at least one ruleset, false if not loaded or not active
*/
export function isRuleActive(ruleKind: ProtectionRuleKind): boolean {
// Safe default: return false if rules not loaded yet
if (!state.rulesets) {
return false
}
return state.rulesets.some((ruleset) => ruleset.rules.includes(ruleKind))
}
/**
* Checks if a user can bypass a specific rule type
* @param ruleKind The rule type to check
* @param userInfo The user information
* @returns true if the rule is not active OR user can bypass ALL rulesets containing it
*/
export function canUserBypassRuleKind(
ruleKind: ProtectionRuleKind,
userInfo: UserExt | undefined
): boolean {
// If no user info, default to permissive
if (!userInfo) {
return false
}
if (!state.rulesets) {
return true // No rules loaded, allow
}
// Find all rulesets containing this rule
const rulesetsWithThisRule = state.rulesets.filter((rs) => rs.rules.includes(ruleKind))
if (rulesetsWithThisRule.length === 0) {
return true // Rule not active
}
// User must be able to bypass ALL rulesets containing this rule
return rulesetsWithThisRule.every((rs) => canUserBypassRule(rs, userInfo))
}
/**
* Returns all rulesets that contain a specific rule kind
*/
export function getActiveRulesetsForKind(ruleKind: ProtectionRuleKind): ProtectionRuleset[] {
if (!state.rulesets) return []
return state.rulesets.filter((rs) => rs.rules.includes(ruleKind))
}
/**
* Checks if a specific rule kind is active in given rulesets (workspace-agnostic version)
* @param rulesets Array of protection rulesets to check
* @param ruleKind The rule type to check
* @returns true if the rule is active in at least one ruleset
*/
export function isRuleActiveInRulesets(
rulesets: ProtectionRuleset[],
ruleKind: ProtectionRuleKind
): boolean {
return rulesets.some((ruleset) => ruleset.rules.includes(ruleKind))
}
/**
* Checks if user can bypass a rule kind in given rulesets (workspace-agnostic version)
* @param rulesets Array of protection rulesets to check
* @param ruleKind The rule type to check
* @param userInfo The user information
* @returns true if the rule is not active OR user can bypass ALL rulesets containing it
*/
export function canUserBypassRuleKindInRulesets(
rulesets: ProtectionRuleset[],
ruleKind: ProtectionRuleKind,
userInfo: UserExt | undefined
): boolean {
// If no user info, default to not allowing bypass
if (!userInfo) {
return false
}
// Find all rulesets containing this rule
const rulesetsWithThisRule = rulesets.filter((rs) => rs.rules.includes(ruleKind))
if (rulesetsWithThisRule.length === 0) {
return true // Rule not active
}
// User must be able to bypass ALL rulesets containing this rule
return rulesetsWithThisRule.every((rs) => canUserBypassRule(rs, userInfo))
}
/**
* Returns rulesets that contain a specific rule kind from given rulesets (workspace-agnostic version)
* @param rulesets Array of protection rulesets to filter
* @param ruleKind The rule type to filter by
* @returns Array of rulesets containing the specified rule
*/
export function getActiveRulesetsForKindInRulesets(
rulesets: ProtectionRuleset[],
ruleKind: ProtectionRuleKind
): ProtectionRuleset[] {
return rulesets.filter((rs) => rs.rules.includes(ruleKind))
}
@@ -59,7 +59,6 @@
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { setContext, untrack } from 'svelte'
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
@@ -433,13 +432,6 @@
}
})
// Load workspace protection rules on workspace change
$effect(() => {
const workspace = $workspaceStore
if (workspace) {
untrack(() => loadProtectionRules(workspace))
}
})
watchOnce(
() => globalDbManagerDrawer.val,
() => {
@@ -40,7 +40,6 @@
import { tutorialsToDo } from '$lib/stores'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import TutorialBanner from '$lib/components/home/TutorialBanner.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
type Tab = 'hub' | 'workspace'
@@ -107,8 +106,6 @@
}
})
let showCreateButtons = $state(false)
onMount(() => {
// Check if there's a tutorial parameter in the URL
const tutorialParam = $page.url.searchParams.get('tutorial')
@@ -117,7 +114,8 @@
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding')
}, 500)
} else if (tutorialParam === 'workspace-onboarding-operator') { // Small delay to ensure page is fully loaded
} else if (tutorialParam === 'workspace-onboarding-operator') {
// Small delay to ensure page is fully loaded
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding-operator')
}, 500)
@@ -283,7 +281,7 @@
title="Home"
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
>
{#if !$userStore?.operator && showCreateButtons}
{#if !$userStore?.operator}
<span class="text-xs font-normal text-primary">Create a</span>
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
@@ -293,8 +291,6 @@
<TutorialBanner />
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v}/>
{#if !$userStore?.operator}
<div class="w-full overflow-auto scrollbar-hidden pb-2">
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
@@ -369,7 +365,7 @@
</div>
{#if tab == 'workspace'}
<ItemsList bind:filter bind:subtab showEditButtons={showCreateButtons} />
<ItemsList bind:filter bind:subtab />
{/if}
</div>
@@ -66,7 +66,6 @@
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
let flow: Flow | undefined = $state()
let can_write = false
@@ -247,7 +246,6 @@
href: `${base}/flows/add?template=${flow.path}`,
variant: 'subtle',
unifiedSize: 'md',
disabled: !showEditButtons,
startIcon: GitFork
}
})
@@ -292,7 +290,6 @@
},
unifiedSize: 'md',
variant: 'subtle',
disabled: !showEditButtons,
startIcon: LayoutDashboard
}
})
@@ -303,7 +300,7 @@
href: `${base}/flows/edit/${path}?nodraft=true`,
variant: 'accent',
unifiedSize: 'md',
disabled: !can_write || !showEditButtons,
disabled: !can_write,
startIcon: Pen
}
})
@@ -338,14 +335,11 @@
disabled: !can_write
})
if (showEditButtons) {
menuItems.push({
label: 'Move/Rename',
onclick: () => moveDrawer?.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
Icon: FolderOpen
})
}
menuItems.push({
label: 'Audit logs',
@@ -363,7 +357,7 @@
})
}
if (can_write && showEditButtons) {
if (can_write) {
menuItems.push({
label: 'Deployments',
onclick: () => flowHistory?.open(),
@@ -439,7 +433,6 @@
}
}
})
let showEditButtons = $state(false)
let mainButtons = $derived(getMainButtons(flow, args))
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
let shouldUseStreaming = $derived.by(() => {
@@ -535,9 +528,6 @@
</DetailPageHeader>
{/snippet}
{#snippet form()}
<div class="px-3">
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
</div>
{#if flow}
<div class="flex flex-col h-full bg-surface divide-y" bind:clientHeight={paneHeight}>
<div bind:clientHeight={topSectionHeight} class={twMerge(chatInputEnabled ? 'h-full' : '')}>
@@ -71,7 +71,6 @@
import ExploreAssetButton, {
assetCanBeExplored
} from '../../../../lib/components/ExploreAssetButton.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
type ResourceTypeW = ResourceType & { canWrite: boolean }
@@ -123,8 +122,6 @@
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let showCreateButtons = $state(false)
let typeFilter: string | undefined = $state(undefined)
async function loadResources(): Promise<void> {
@@ -730,32 +727,29 @@
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
>
{#if showCreateButtons}
<div class="flex flex-row justify-end gap-4">
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Plus }}
on:click={startNewType}
aiId="resources-add-resource-type"
aiDescription="Add resource type"
>
Add resource type
</Button>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Boxes }}
on:click={() => appConnect?.open?.()}
aiId="resources-add-resource"
aiDescription="Add resource"
>
Add resource
</Button>
</div>
{/if}
<div class="flex flex-row justify-end gap-4">
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Plus }}
on:click={startNewType}
aiId="resources-add-resource-type"
aiDescription="Add resource type"
>
Add resource type
</Button>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Boxes }}
on:click={() => appConnect?.open?.()}
aiId="resources-add-resource"
aiDescription="Add resource"
>
Add resource
</Button>
</div>
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v} />
<div class="flex justify-between">
<Tabs
class="w-full"
@@ -1018,7 +1012,7 @@
{
displayName: 'Edit',
icon: Pen,
disabled: !canWrite || !showCreateButtons,
disabled: !canWrite,
action: () => {
resourceEditor?.initEdit?.(path)
}
@@ -1036,7 +1030,7 @@
: []),
{
displayName: 'Delete',
disabled: !canWrite || !showCreateButtons,
disabled: !canWrite,
icon: Trash,
type: 'delete',
action: (event) => {
@@ -1141,7 +1135,6 @@
<Button
size="xs"
variant="default"
disabled={!showCreateButtons}
btnClasses="border-0"
startIcon={{ icon: Trash }}
on:click={() => handleDeleteResourceType(name)}
@@ -1152,7 +1145,6 @@
<Button
size="xs"
color="light"
disabled={!showCreateButtons}
startIcon={{ icon: Pen }}
on:click={() => startEditResourceType(name)}
>
@@ -85,7 +85,6 @@
import { twMerge } from 'tailwind-merge'
import FlowRestartButton from '$lib/components/FlowRestartButton.svelte'
import JobOtelTraces from '$lib/components/JobOtelTraces.svelte'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
let jobUpdateLastFetch: Date | undefined = $state()
@@ -355,8 +354,6 @@
}
}
let showEditButton = $derived(!isRuleActive('RequireForkOrBranchToDeploy'))
$effect(() => {
job?.id && lastJobId !== job.id && untrack(() => getConcurrencyKey(job))
})
@@ -643,7 +640,6 @@
}}
unifiedSize="md"
variant="default"
disabled={!showEditButton}
size="sm"
startIcon={{ icon: Pen }}>Edit</Button
>
@@ -40,7 +40,6 @@
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import { sendUserToast } from '$lib/toast'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
@@ -326,7 +325,6 @@
let moveDrawer: MoveDrawer | undefined = $state()
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let persistentScriptDrawer: PersistentScriptDrawer | undefined = $state()
let showEditButtons = $state(false)
function getMainButtons(
script: Script | undefined,
@@ -343,7 +341,6 @@
href: `${base}/scripts/add?template=${script.path}`,
unifiedSize: 'md',
variant: 'subtle',
disabled: !showEditButtons,
startIcon: GitFork
}
})
@@ -391,7 +388,7 @@
$importStore = JSON.parse(JSON.stringify(app))
await goto('/apps/add?nodraft=true')
},
disabled: !showEditButtons,
unifiedSize: 'md',
variant: 'subtle',
startIcon: LayoutDashboard
@@ -422,7 +419,7 @@
unifiedSize: 'md',
startIcon: Pen,
variant: 'accent',
disabled: !can_write || !showEditButtons
disabled: !can_write
}
})
}
@@ -451,15 +448,13 @@
const menuItems: any = []
if (showEditButtons) {
menuItems.push({
label: 'Move/Rename',
Icon: FolderOpen,
onclick: () => {
moveDrawer?.openDrawer(script?.path ?? '', script?.summary, 'script')
}
})
}
menuItems.push({
label: 'Move/Rename',
Icon: FolderOpen,
onclick: () => {
moveDrawer?.openDrawer(script?.path ?? '', script?.summary, 'script')
}
})
menuItems.push({
label: 'Audit logs',
@@ -511,37 +506,35 @@
})
}
if (showEditButtons) {
if (script.archived) {
menuItems.push({
label: 'Unarchive',
Icon: ArchiveRestore,
onclick: async () => {
unarchiveScript(script.hash)
},
color: 'red'
})
} else {
menuItems.push({
label: 'Archive',
Icon: Archive,
onclick: async () => {
archiveScript(script.hash)
},
color: 'red'
})
}
if (script.archived) {
menuItems.push({
label: 'Delete',
Icon: Trash,
label: 'Unarchive',
Icon: ArchiveRestore,
onclick: async () => {
deleteScript(script.hash)
unarchiveScript(script.hash)
},
color: 'red'
})
} else {
menuItems.push({
label: 'Archive',
Icon: Archive,
onclick: async () => {
archiveScript(script.hash)
},
color: 'red'
})
}
menuItems.push({
label: 'Delete',
Icon: Trash,
onclick: async () => {
deleteScript(script.hash)
},
color: 'red'
})
return menuItems
}
@@ -687,9 +680,6 @@
</DetailPageHeader>
{/snippet}
{#snippet form()}
<div class="px-3">
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
</div>
{#if script}
<div class="p-8 w-full max-w-3xl mx-auto min-h-[300px] flex flex-col justify-center">
<div class="flex flex-col gap-0.5 mb-1">
@@ -6,7 +6,6 @@
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
@@ -46,8 +45,6 @@
let filter = $state('')
let variables = $state(undefined) as ListableVariableW[] | undefined
let filteredItems = $state(undefined) as (ListableVariableW & { marked?: string })[] | undefined
let showCreateButtons = $state(false)
let contextualVariables: ContextualVariable[] = $state([])
let shareModal: ShareModal | undefined = $state()
let variableEditor: VariableEditor | undefined = $state()
@@ -186,31 +183,28 @@
tooltip="Save and permission strings to be reused in Scripts and Flows."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
>
{#if showCreateButtons}
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor?.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
{/if}
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor?.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v} />
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
<ContextualVariableEditor
@@ -410,7 +404,7 @@
displayName: 'Edit',
icon: Pen,
action: () => variableEditor?.editVariable(path),
disabled: !canWrite || !showCreateButtons
disabled: !canWrite
},
{
displayName: 'Delete',
@@ -425,7 +419,7 @@
}
}
},
disabled: !owner || !showCreateButtons
disabled: !owner
},
...(isDeployable(
is_secret ? 'secret' : 'variable',
@@ -66,7 +66,6 @@
type DataTableSettingsType
} from '$lib/components/workspaceSettings/DataTableSettings.svelte'
import WorkspaceDependenciesSettings from '$lib/components/workspaceSettings/WorkspaceDependenciesSettings.svelte'
import WorkspaceRulesets from '$lib/components/workspaceSettings/WorkspaceRulesets.svelte'
let slackInitialPath: string = $state('')
let slackScriptPath: string = $state('')
@@ -162,7 +161,6 @@
| 'native_triggers'
| 'encryption'
| 'dependencies'
| 'deployment'
// Both 'slack' and 'teams' URLs map to 'slack' tab
if (selectedTab === 'teams') {
return 'slack'
@@ -721,10 +719,10 @@
/>
<Tab
small
value="deployment"
aiId="workspace-settings-deployment"
aiDescription="Deployment and rulesets"
label="Deployment"
value="deploy_to"
aiId="workspace-settings-deploy-to"
aiDescription="Deployment UI workspace settings"
label="Deployment UI"
/>
{#if WORKSPACE_SHOW_SLACK_CMD}
@@ -825,8 +823,7 @@
<Skeleton layout={[1, [40]]} />
{:else if tab == 'users'}
<WorkspaceUserSettings />
{:else if tab == 'deployment'}
<WorkspaceRulesets />
{:else if tab == 'deploy_to'}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis">