mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
This reverts commit a1cd02d7f8.
This commit is contained in:
-46
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
-18
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
-18
@@ -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);
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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 contextual variable
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => variableEditor?.initNew()}
|
||||
>
|
||||
New 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 contextual variable
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => variableEditor?.initNew()}
|
||||
>
|
||||
New 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">
|
||||
|
||||
Reference in New Issue
Block a user