mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
feat: restriction rulesets for workspaces (#7879)
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n name,\n rules as \"rules: ProtectionRules\",\n bypass_groups,\n bypass_users\n FROM workspace_protection_rule\n WHERE workspace_id = $1\n ORDER BY name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "rules: ProtectionRules",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "bypass_groups",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "bypass_users",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_protection_rule\n SET rules = $1, bypass_groups = $2, bypass_users = $3\n WHERE workspace_id = $4 AND name = $5\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1"
|
||||
}
|
||||
Generated
+1
@@ -16088,6 +16088,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Drop the workspace_protection_rule table and its indexes
|
||||
DROP TABLE IF EXISTS workspace_protection_rule;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add workspace_protection_rule table for fine-grained access control
|
||||
CREATE TABLE workspace_protection_rule (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
rules INTEGER NOT NULL,
|
||||
bypass_groups TEXT[] NOT NULL DEFAULT '{}',
|
||||
bypass_users TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, name)
|
||||
);
|
||||
@@ -16,9 +16,9 @@ use axum::{
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::webhook::{WebhookMessage, WebhookShared};
|
||||
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -174,6 +174,19 @@ async fn create_folder(
|
||||
Path(w_id): Path<String>,
|
||||
Json(ng): Json<NewFolder>,
|
||||
) -> Result<String> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
if !VALID_FOLDER_NAME.is_match(&ng.name) {
|
||||
@@ -315,6 +328,19 @@ async fn update_folder(
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("folder");
|
||||
sqlb.and_where_eq("name", "?".bind(&name));
|
||||
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||
@@ -604,6 +630,18 @@ async fn delete_folder(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
|
||||
@@ -18,6 +18,7 @@ use windmill_api_auth::{
|
||||
auth::{list_tokens_internal, TruncatedTokenWithEmail},
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
};
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
use windmill_common::{
|
||||
utils::{WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
@@ -29,7 +30,7 @@ use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind};
|
||||
use windmill_common::min_version::{
|
||||
@@ -429,6 +430,20 @@ async fn create_flow(
|
||||
));
|
||||
}
|
||||
check_scopes(&authed, || format!("flows:write:{}", nf.path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
validate_flow(&nf).await?;
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_flows =
|
||||
@@ -863,6 +878,20 @@ async fn update_flow(
|
||||
}
|
||||
let flow_path = flow_path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", flow_path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
validate_flow(&nf).await?;
|
||||
|
||||
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
|
||||
@@ -1417,6 +1446,18 @@ async fn archive_flow_by_path(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -1522,6 +1563,18 @@ async fn delete_flow_by_path(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
|
||||
@@ -12,9 +12,7 @@ use windmill_api_auth::{
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
};
|
||||
use windmill_common::{
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
DB,
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, DB
|
||||
};
|
||||
use windmill_queue::schedule::clear_schedule;
|
||||
|
||||
@@ -35,7 +33,7 @@ use serde_json::value::RawValue;
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_dep_map::process_relative_imports;
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
@@ -548,6 +546,18 @@ async fn create_script(
|
||||
Path(w_id): Path<String>,
|
||||
Json(ns): Json<NewScript>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let (hash, tx, hdm) =
|
||||
create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?;
|
||||
tx.commit().await?;
|
||||
@@ -1906,6 +1916,18 @@ async fn archive_script_by_path(
|
||||
}
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_owner_of_path(&authed, path)?;
|
||||
@@ -1973,6 +1995,18 @@ async fn archive_script_by_hash(
|
||||
"Operators cannot archive scripts for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
|
||||
@@ -2018,9 +2052,20 @@ async fn delete_script_by_hash(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||
) -> JsonResult<Script<ScriptRunnableSettingsInline>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
|
||||
"UPDATE script SET content = '', archived = true, deleted = true, lock = '', schema = null WHERE hash = $1 AND \
|
||||
workspace_id = $2 RETURNING *",
|
||||
@@ -2078,6 +2123,19 @@ async fn delete_script_by_path(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let draft_only = sqlx::query_scalar!(
|
||||
@@ -2204,6 +2262,19 @@ async fn delete_scripts_bulk(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let mut deleted_paths = sqlx::query_scalar!(
|
||||
|
||||
@@ -39,3 +39,4 @@ sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
@@ -26,7 +26,8 @@ use hex;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use strum::{IntoEnumIterator};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::s3_helpers::LargeFileStorage;
|
||||
@@ -38,8 +39,7 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType,
|
||||
WorkspaceGitSyncSettings,
|
||||
check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -147,6 +147,12 @@ pub fn workspaced_service() -> Router {
|
||||
post(reset_workspace_diffs),
|
||||
)
|
||||
.route("/compare/:target_workspace_id", get(compare_workspaces))
|
||||
.route("/protection_rules", get(list_protection_rules))
|
||||
.route("/protection_rules", post(create_protection_rule))
|
||||
.route(
|
||||
"/protection_rules/:rule_name",
|
||||
post(update_protection_rule).delete(delete_protection_rule),
|
||||
)
|
||||
}
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -3382,6 +3388,18 @@ async fn create_workspace_fork_branch(
|
||||
if *DISABLE_WORKSPACE_FORK {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableWorkspaceForking,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
Ok(Json(
|
||||
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
|
||||
@@ -3403,6 +3421,18 @@ async fn create_workspace_fork(
|
||||
if *DISABLE_WORKSPACE_FORK {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&parent_workspace_id,
|
||||
&ProtectionRuleKind::DisableWorkspaceForking,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
@@ -4285,6 +4315,268 @@ async fn update_operator_settings(
|
||||
Ok("Operator settings updated successfully".to_string())
|
||||
}
|
||||
|
||||
// Protection Rules API endpoints
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateProtectionRuleRequest {
|
||||
name: String,
|
||||
rules: Vec<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)>,
|
||||
Json(req): Json<UpdateProtectionRuleRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Check if rule exists
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||||
&w_id,
|
||||
&rule_name
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
return Err(Error::NotFound(format!(
|
||||
"Protection rule '{}' not found",
|
||||
rule_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Update the rule
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_protection_rule
|
||||
SET rules = $1, bypass_groups = $2, bypass_users = $3
|
||||
WHERE workspace_id = $4 AND name = $5
|
||||
"#,
|
||||
ProtectionRules::from(&req.rules).bits(),
|
||||
&req.bypass_groups,
|
||||
&req.bypass_users,
|
||||
&w_id,
|
||||
&rule_name
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.update_protection_rule",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&rule_name),
|
||||
Some([("name", &rule_name[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Invalidate cache
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Updated protection rule '{}'", rule_name))
|
||||
}
|
||||
|
||||
/// Delete a protection rule
|
||||
async fn delete_protection_rule(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, rule_name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Delete the rule
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||||
&w_id,
|
||||
&rule_name
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::NotFound(format!(
|
||||
"Protection rule '{}' not found",
|
||||
rule_name
|
||||
)));
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.delete_protection_rule",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&rule_name),
|
||||
Some([("name", &rule_name[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Invalidate cache
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Deleted protection rule '{}'", rule_name))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WorkspaceComparison {
|
||||
pub all_ahead_items_visible: bool,
|
||||
|
||||
@@ -4169,6 +4169,126 @@ paths:
|
||||
type: string
|
||||
example: "Updated mute critical alert UI settings for workspace: workspace_id"
|
||||
|
||||
/w/{workspace}/workspaces/protection_rules:
|
||||
get:
|
||||
summary: list all protection rules for a workspace
|
||||
operationId: listProtectionRules
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of protection rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleset"
|
||||
post:
|
||||
summary: create a new protection rule
|
||||
operationId: createProtectionRule
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: New protection rule configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- rules
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Unique name for the protection rule
|
||||
example: "Production Protection"
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
$ref: "#/components/schemas/RuleBypasserGroups"
|
||||
bypass_users:
|
||||
$ref: "#/components/schemas/RuleBypasserUsers"
|
||||
responses:
|
||||
"200":
|
||||
description: protection rule created successfully
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: rule with this name already exists
|
||||
|
||||
/w/{workspace}/workspaces/protection_rules/{rule_name}:
|
||||
post:
|
||||
summary: update an existing protection rule
|
||||
operationId: updateProtectionRule
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: rule_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Name of the protection rule to update
|
||||
requestBody:
|
||||
description: Updated protection rule configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- rules
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
$ref: "#/components/schemas/RuleBypasserGroups"
|
||||
bypass_users:
|
||||
$ref: "#/components/schemas/RuleBypasserUsers"
|
||||
responses:
|
||||
"200":
|
||||
description: protection rule updated successfully
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: protection rule not found
|
||||
delete:
|
||||
summary: delete a protection rule
|
||||
operationId: deleteProtectionRule
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: rule_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Name of the protection rule to delete
|
||||
responses:
|
||||
"200":
|
||||
description: protection rule deleted successfully
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: protection rule not found
|
||||
/w/{workspace}/workspaces/public_app_rate_limit:
|
||||
post:
|
||||
summary: Set public app rate limit for this workspace
|
||||
@@ -22983,7 +23103,47 @@ components:
|
||||
kind:
|
||||
$ref: "#/components/schemas/AssetKind"
|
||||
required: [path, kind]
|
||||
|
||||
ProtectionRuleset:
|
||||
type: object
|
||||
description: A workspace protection rule defining restrictions and bypass permissions
|
||||
required:
|
||||
- name
|
||||
- rules
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Unique name for the protection rule
|
||||
example: "Production Protection"
|
||||
workspace_id:
|
||||
type: string
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
$ref: "#/components/schemas/RuleBypasserGroups"
|
||||
bypass_users:
|
||||
$ref: "#/components/schemas/RuleBypasserUsers"
|
||||
ProtectionRules:
|
||||
type: array
|
||||
description: Configuration of protection restrictions
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleKind"
|
||||
ProtectionRuleKind:
|
||||
type: string
|
||||
enum:
|
||||
- DisableDirectDeployment
|
||||
- DisableWorkspaceForking
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
items:
|
||||
type: string
|
||||
RuleBypasserUsers:
|
||||
type: array
|
||||
description: Users that can bypass this ruleset
|
||||
items:
|
||||
type: string
|
||||
NativeServiceName:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -49,7 +49,7 @@ use sha2::{Digest, Sha256};
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
use sqlx::{types::Uuid, FromRow};
|
||||
use std::str;
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
|
||||
@@ -65,6 +65,7 @@ use windmill_common::{
|
||||
},
|
||||
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
HUB_BASE_URL,
|
||||
};
|
||||
|
||||
@@ -1049,6 +1050,20 @@ async fn create_app_raw<'a>(
|
||||
"Operators cannot create apps for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let (path, _id) = process_app_multipart!(
|
||||
authed,
|
||||
user_db,
|
||||
@@ -1109,6 +1124,19 @@ async fn create_app(
|
||||
let path = app.path.clone();
|
||||
check_scopes(&authed, || format!("apps:write:{}", &path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let (new_tx, _path, _id) = create_app_internal(authed, db, user_db, &w_id, false, app).await?;
|
||||
|
||||
new_tx.commit().await?;
|
||||
@@ -1347,6 +1375,19 @@ async fn delete_app(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
// Check if it's a raw app before deletion
|
||||
let is_raw_app = sqlx::query_scalar!(
|
||||
"SELECT app_version.raw_app FROM app
|
||||
@@ -1452,6 +1493,21 @@ async fn update_app(
|
||||
// create_app_internal(authed, user_db, db, &w_id, &mut app).await?;
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:write:{}", path))?;
|
||||
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let opath = path.to_string();
|
||||
let (new_tx, npath, _v_id) =
|
||||
update_app_internal(authed, db, user_db, &w_id, path, false, ns).await?;
|
||||
@@ -1482,6 +1538,21 @@ async fn update_app_raw<'a>(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:write:{}", path))?;
|
||||
let opath = path.to_string();
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
pub use windmill_store::resources::*;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::get,
|
||||
use axum::{ extract::{Extension, Path}, routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
#[cfg(feature = "mcp")]
|
||||
|
||||
@@ -13,6 +13,103 @@ 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 DISABLE_DIRECT_DEPLOYMENT = 1 << 0;
|
||||
const DISABLE_WORKSPACE_FORKING = 1 << 1;
|
||||
}
|
||||
}
|
||||
|
||||
sqlx_bitflags!(ProtectionRules => i32);
|
||||
|
||||
#[derive(Serialize, Deserialize, strum_macros::EnumIter)]
|
||||
pub enum ProtectionRuleKind {
|
||||
DisableDirectDeployment,
|
||||
DisableWorkspaceForking,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
pub const fn flag(&self) -> ProtectionRules {
|
||||
match self {
|
||||
ProtectionRuleKind::DisableDirectDeployment => {
|
||||
ProtectionRules::DISABLE_DIRECT_DEPLOYMENT
|
||||
}
|
||||
ProtectionRuleKind::DisableWorkspaceForking => {
|
||||
ProtectionRules::DISABLE_WORKSPACE_FORKING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn msg(&self) -> &str {
|
||||
match self {
|
||||
ProtectionRuleKind::DisableDirectDeployment => {
|
||||
"Cannot directly deploy in this workspace. Fork or Pull request required."
|
||||
}
|
||||
ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Vec<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")]
|
||||
@@ -176,6 +273,111 @@ 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))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Ok(RuleCheckResult::Blocked(format!(
|
||||
"Ruleset {} of {} blocked this action: {}",
|
||||
ruleset.name,
|
||||
workspace_id,
|
||||
rule.msg()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RuleCheckResult::Allowed)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTable {
|
||||
pub database: DataTableDatabase,
|
||||
|
||||
@@ -13,6 +13,7 @@ use windmill_api_auth::{
|
||||
Tokened,
|
||||
};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::rename_vault_secret;
|
||||
use crate::var_resource_cache::{cache_resource, get_cached_resource};
|
||||
@@ -683,6 +684,18 @@ async fn create_resource(
|
||||
Json(resource): Json<CreateResource>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_scopes(&authed, || format!("resources:write:{}", resource.path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_resources = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
|
||||
@@ -793,6 +806,18 @@ async fn delete_resource(
|
||||
let path = path.to_path();
|
||||
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let deleted_path = sqlx::query_scalar!(
|
||||
@@ -854,6 +879,19 @@ async fn delete_resources_bulk(
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let deleted_paths = sqlx::query_scalar!(
|
||||
@@ -916,6 +954,18 @@ async fn update_resource(
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("resource");
|
||||
sqlb.and_where_eq("path", "?".bind(&path));
|
||||
@@ -1042,6 +1092,18 @@ async fn update_resource_value(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -1193,6 +1255,19 @@ async fn create_resource_type(
|
||||
Path(w_id): Path<String>,
|
||||
Json(resource_type): Json<CreateResourceType>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?;
|
||||
@@ -1279,6 +1354,18 @@ async fn delete_resource_type(
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
@@ -1333,6 +1420,18 @@ async fn update_resource_type(
|
||||
Json(ns): Json<EditResourceType>,
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("resource_type");
|
||||
sqlb.and_where_eq("name", "?".bind(&name));
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::{
|
||||
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
|
||||
@@ -339,6 +340,19 @@ async fn create_variable(
|
||||
Json(variable): Json<CreateVariable>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_scopes(&authed, || format!("variables:write:{}", variable.path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_variables = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
|
||||
@@ -438,6 +452,18 @@ async fn delete_variable(
|
||||
let path = path.to_path();
|
||||
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
// Check if variable is a secret before deleting (for Vault cleanup)
|
||||
let is_secret = sqlx::query_scalar!(
|
||||
@@ -515,6 +541,19 @@ async fn delete_variables_bulk(
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
// Query which paths are secrets before deletion (for Vault cleanup)
|
||||
let secret_paths: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT path FROM variable WHERE path = ANY($1) AND workspace_id = $2 AND is_secret = true",
|
||||
@@ -613,6 +652,19 @@ async fn update_variable(
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
} from '$lib/gen'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import DiffDrawer from './DiffDrawer.svelte'
|
||||
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
||||
import { getAllModules } from './flows/flowExplorer'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
|
||||
@@ -51,6 +52,7 @@
|
||||
let mergeIntoParent = $state(true)
|
||||
let deploying = $state(false)
|
||||
let hasAutoSelected = $state(false)
|
||||
let canDeployToParent = $state(true)
|
||||
|
||||
let selectableDiffs = $derived(
|
||||
comparison?.diffs.filter((diff) => {
|
||||
@@ -783,6 +785,14 @@
|
||||
</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" /> -->
|
||||
@@ -793,7 +803,7 @@
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if hasBehindChanges && hasAheadChanges}
|
||||
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
|
||||
<Alert
|
||||
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
|
||||
type="warning"
|
||||
@@ -986,20 +996,23 @@
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
|
||||
<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 !(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}
|
||||
{/if}
|
||||
|
||||
{#if deploymentErrorMessage != ''}
|
||||
@@ -1022,6 +1035,4 @@
|
||||
<div class="text-gray-500">No comparison data available</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- <DeployWorkspaceItems kind="script" initialPath="u/admin/economical_script" workspaceToDeployTo={parentWorkspaceId} /> -->
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
additionalInformation?: AdditionalInformation | undefined
|
||||
workspaceToDeployTo?: string | undefined
|
||||
hideButton?: boolean
|
||||
canDeployToWorkspace?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,7 +51,8 @@
|
||||
initialPath = '',
|
||||
additionalInformation = undefined,
|
||||
workspaceToDeployTo = $bindable(undefined),
|
||||
hideButton = false
|
||||
hideButton = false,
|
||||
canDeployToWorkspace = $bindable(false)
|
||||
}: Props = $props()
|
||||
|
||||
let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined =
|
||||
@@ -735,7 +737,7 @@
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button color="light" size="xs" on:click={() => deploy(kind, path)}>Deploy</Button>
|
||||
<Button color="light" size="xs" disabled={!canDeployToWorkspace} on:click={() => deploy(kind, path)}>Deploy</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -743,7 +745,7 @@
|
||||
|
||||
{#if !hideButton}
|
||||
<div class="mt-16 flex flex-row-reverse max-w-3xl"
|
||||
><Button on:click={deployAll}>Deploy all toggled</Button></div
|
||||
><Button on:click={deployAll} disabled={!canDeployToWorkspace}>Deploy all toggled</Button></div
|
||||
>
|
||||
{/if}
|
||||
{:else if canSeeTarget == 'cant-see-all-deps'}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
import { Button, Drawer } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import DeployWorkspace from './DeployWorkspace.svelte'
|
||||
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
||||
import { type AdditionalInformation, type Kind } from '$lib/utils_deployable'
|
||||
|
||||
let initialPath: string | undefined = undefined
|
||||
let kind: Kind | undefined = undefined
|
||||
let drawer: Drawer | undefined = undefined
|
||||
let workspaceToDeployTo: string | undefined = undefined
|
||||
let deployWorkspace: DeployWorkspace | undefined = undefined
|
||||
let additionalInformation: AdditionalInformation | undefined = undefined
|
||||
let initialPath: string | undefined = $state(undefined)
|
||||
let kind: Kind | undefined = $state(undefined)
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let workspaceToDeployTo: string | undefined = $state(undefined)
|
||||
let deployWorkspace: DeployWorkspace | undefined = $state(undefined)
|
||||
let additionalInformation: AdditionalInformation | undefined = $state(undefined)
|
||||
let canDeployToWorkspace = $state(true)
|
||||
|
||||
export async function openDrawer(
|
||||
initialPath_l: string,
|
||||
@@ -25,6 +27,14 @@
|
||||
|
||||
<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
|
||||
@@ -33,12 +43,13 @@
|
||||
{additionalInformation}
|
||||
bind:workspaceToDeployTo
|
||||
bind:this={deployWorkspace}
|
||||
bind:canDeployToWorkspace
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
disabled={workspaceToDeployTo == undefined}
|
||||
disabled={workspaceToDeployTo == undefined || !canDeployToWorkspace}
|
||||
on:click={() => deployWorkspace?.deployAll()}>Deploy All</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { userStore } from '$lib/stores'
|
||||
import {
|
||||
canUserBypassRuleKind,
|
||||
getActiveRulesetsForKind,
|
||||
isRuleActive
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { Alert } from './common'
|
||||
|
||||
let activeDeployRulesets = $derived(getActiveRulesetsForKind('DisableDirectDeployment'))
|
||||
let canBypass = $derived(canUserBypassRuleKind('DisableDirectDeployment', $userStore))
|
||||
let overrideChecked = $state(false)
|
||||
let canEdit = $derived(
|
||||
!isRuleActive('DisableDirectDeployment') || (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. You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.
|
||||
</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}
|
||||
@@ -0,0 +1,75 @@
|
||||
<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, 'DisableDirectDeployment')
|
||||
)
|
||||
|
||||
let canBypass = $derived(
|
||||
canUserBypassRuleKindInRulesets(parentRulesets, 'DisableDirectDeployment', $userStore)
|
||||
)
|
||||
|
||||
let canDeploy = $derived(
|
||||
!isRuleActiveInRulesets(parentRulesets, 'DisableDirectDeployment') ||
|
||||
(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 a 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,6 +42,7 @@
|
||||
deleteConfirmedCallback: (() => void) | undefined
|
||||
depth?: number
|
||||
menuOpen?: boolean
|
||||
showEditButton?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -52,7 +53,8 @@
|
||||
deploymentDrawer,
|
||||
deleteConfirmedCallback = $bindable(),
|
||||
depth = 0,
|
||||
menuOpen = $bindable(false)
|
||||
menuOpen = $bindable(false),
|
||||
showEditButton = $bindable(true)
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -95,7 +97,7 @@
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if !$userStore?.operator}
|
||||
{#if !$userStore?.operator && showEditButton}
|
||||
{#if app.canWrite}
|
||||
<div>
|
||||
<Button
|
||||
@@ -132,6 +134,7 @@
|
||||
items={async () => {
|
||||
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
|
||||
|
||||
const canEdit = canWrite && showEditButton
|
||||
if (draft_only) {
|
||||
return [
|
||||
{
|
||||
@@ -151,7 +154,7 @@
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite,
|
||||
disabled: !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
@@ -168,6 +171,7 @@
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `${base}/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
@@ -176,7 +180,7 @@
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, summary, 'app')
|
||||
},
|
||||
disabled: !canWrite,
|
||||
disabled: !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
...(isDeployable('app', path, await getDeployUiSettings())
|
||||
@@ -274,7 +278,7 @@
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite,
|
||||
disabled: !canEdit,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
errorHandlerMuted: boolean
|
||||
depth?: number
|
||||
menuOpen?: boolean
|
||||
showEditButton?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -56,7 +57,8 @@
|
||||
deploymentDrawer,
|
||||
errorHandlerMuted,
|
||||
depth = 0,
|
||||
menuOpen = $bindable(false)
|
||||
menuOpen = $bindable(false),
|
||||
showEditButton = $bindable(true)
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -118,7 +120,7 @@
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if !$userStore?.operator}
|
||||
{#if !$userStore?.operator && showEditButton}
|
||||
{#if flow.canWrite && !flow.archived}
|
||||
<div>
|
||||
<Button
|
||||
@@ -157,6 +159,7 @@
|
||||
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 [
|
||||
{
|
||||
@@ -179,17 +182,18 @@
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `${base}/flows/add?template=${path}`,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
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: 'Audit logs',
|
||||
icon: Eye,
|
||||
@@ -202,7 +206,7 @@
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, flow.summary, 'flow')
|
||||
},
|
||||
disabled: !owner || archived,
|
||||
disabled: !owner || archived || !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
@@ -257,7 +261,7 @@
|
||||
path && archiveFlow(path, !archived)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner,
|
||||
disabled: !owner || !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
...(has_draft
|
||||
@@ -293,7 +297,7 @@
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner,
|
||||
disabled: !owner || !canEdit,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
showCode: (path: string, summary: string) => void
|
||||
depth?: number
|
||||
menuOpen?: boolean
|
||||
showEditButton?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -67,7 +68,8 @@
|
||||
errorHandlerMuted,
|
||||
showCode,
|
||||
depth = 0,
|
||||
menuOpen = $bindable(false)
|
||||
menuOpen = $bindable(false),
|
||||
showEditButton = $bindable(true)
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -148,7 +150,7 @@
|
||||
|
||||
{#snippet actions()}
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if !$userStore?.operator}
|
||||
{#if !$userStore?.operator && showEditButton}
|
||||
{#if script.use_codebase}
|
||||
<Badge
|
||||
>bundle<Tooltip
|
||||
@@ -191,6 +193,7 @@
|
||||
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 [
|
||||
{
|
||||
@@ -215,7 +218,7 @@
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !script.canWrite
|
||||
disabled: !canEdit
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -231,6 +234,7 @@
|
||||
displayName: 'Duplicate/Fork',
|
||||
icon: GitFork,
|
||||
href: `${base}/scripts/add?template=${script.path}`,
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
@@ -239,7 +243,7 @@
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(script.path, script.summary, 'script')
|
||||
},
|
||||
disabled: !owner || script.archived,
|
||||
disabled: !owner || script.archived || !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
...(isDeployable('script', script.path, await getDeployUiSettings())
|
||||
@@ -330,7 +334,7 @@
|
||||
: script.path && archiveScript(script.path)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner,
|
||||
disabled: !owner || !canEdit,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
|
||||
@@ -368,7 +372,7 @@
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !script.canWrite,
|
||||
disabled: !canEdit,
|
||||
hide: $userStore?.operator
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
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
|
||||
@@ -49,35 +48,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'
|
||||
<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'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Import low-code app from JSON',
|
||||
onClick: () => {
|
||||
drawer?.toggleDrawer?.()
|
||||
importType = 'json'
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-row items-center"> App </div>
|
||||
</Button>
|
||||
</div>
|
||||
]}
|
||||
>
|
||||
<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,7 +9,6 @@
|
||||
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')
|
||||
@@ -24,35 +23,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'
|
||||
<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'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Import from JSON',
|
||||
onClick: () => {
|
||||
drawer?.toggleDrawer?.()
|
||||
importType = 'json'
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
Flow
|
||||
</Button>
|
||||
</div>
|
||||
]}
|
||||
>
|
||||
Flow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
let menuOpen: boolean = false
|
||||
export let showCode: (path: string, summary: string) => void
|
||||
export let showEditButton: boolean = true
|
||||
</script>
|
||||
|
||||
{#if item.type == 'script'}
|
||||
@@ -41,6 +42,7 @@
|
||||
{depth}
|
||||
bind:menuOpen
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
/>
|
||||
{:else if item.type == 'flow'}
|
||||
<FlowRow
|
||||
@@ -57,6 +59,7 @@
|
||||
{deploymentDrawer}
|
||||
{depth}
|
||||
bind:menuOpen
|
||||
{showEditButton}
|
||||
/>
|
||||
{:else if item.type == 'app'}
|
||||
<AppRow
|
||||
@@ -69,6 +72,7 @@
|
||||
{deploymentDrawer}
|
||||
{depth}
|
||||
bind:menuOpen
|
||||
{showEditButton}
|
||||
/>
|
||||
{:else if item.type == 'raw_app'}
|
||||
<RawAppRow
|
||||
|
||||
@@ -45,9 +45,10 @@
|
||||
interface Props {
|
||||
filter?: string
|
||||
subtab?: 'flow' | 'script' | 'app'
|
||||
showEditButtons?: boolean
|
||||
}
|
||||
|
||||
let { filter = $bindable(''), subtab = $bindable('script') }: Props = $props()
|
||||
let { filter = $bindable(''), subtab = $bindable('script'), showEditButtons = true }: Props = $props()
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
canWrite: boolean
|
||||
@@ -552,6 +553,7 @@
|
||||
loadRawApps()
|
||||
}}
|
||||
{showCode}
|
||||
showEditButton={showEditButtons}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
let { aiId, aiDescription }: { aiId: string; aiDescription: string } = $props()
|
||||
</script>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
id="create-script-button"
|
||||
|
||||
@@ -180,6 +180,11 @@
|
||||
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,6 +9,7 @@
|
||||
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'
|
||||
@@ -177,7 +178,7 @@
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted()}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted() && !isRuleActive('DisableWorkspaceForking')}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem href="{base}/user/fork_workspace" class={itemClass} {item}>
|
||||
<GitFork size={16} />
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<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 disableDirectDeployment = $state(hasRule('DisableDirectDeployment'))
|
||||
let disableFork = $state(hasRule('DisableWorkspaceForking'))
|
||||
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 initialDisableDirectDeployment = $state(hasRule('DisableDirectDeployment'))
|
||||
let initialDisableFork = $state(hasRule('DisableWorkspaceForking'))
|
||||
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() !== '' ||
|
||||
disableDirectDeployment ||
|
||||
disableFork ||
|
||||
selectedGroups.length > 0 ||
|
||||
selectedUsers.length > 0
|
||||
: name !== initialName ||
|
||||
disableDirectDeployment !== initialDisableDirectDeployment ||
|
||||
disableFork !== initialDisableFork ||
|
||||
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: [
|
||||
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
|
||||
...(disableFork ? ['DisableWorkspaceForking' 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: [
|
||||
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
|
||||
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
|
||||
],
|
||||
bypass_groups: selectedGroups,
|
||||
bypass_users: selectedUsers
|
||||
}
|
||||
})
|
||||
|
||||
sendUserToast('Protection rule saved successfully')
|
||||
|
||||
// Update initial state
|
||||
initialName = name
|
||||
initialDisableDirectDeployment = disableDirectDeployment
|
||||
initialDisableFork = disableFork
|
||||
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">
|
||||
<!-- Disable Direct Deployment -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
bind:checked={disableDirectDeployment}
|
||||
options={{
|
||||
right: 'Disable direct deployment'
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
|
||||
</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>
|
||||
@@ -0,0 +1,187 @@
|
||||
<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 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>
|
||||
|
||||
|
||||
{#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,6 +8,7 @@ import {
|
||||
devopsRole,
|
||||
clearWorkspaceFromStorage
|
||||
} from './stores'
|
||||
import { resetProtectionRules, loadProtectionRules } from './workspaceProtectionRules.svelte'
|
||||
|
||||
export function switchWorkspace(workspace: string | undefined) {
|
||||
try {
|
||||
@@ -17,7 +18,16 @@ 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 {
|
||||
@@ -30,6 +40,7 @@ export function clearStores(): void {
|
||||
}
|
||||
|
||||
resourceTypesStore.set(undefined)
|
||||
resetProtectionRules()
|
||||
userStore.set(undefined)
|
||||
workspaceStore.set(undefined)
|
||||
usersWorkspaceStore.set(undefined)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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))
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
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'
|
||||
@@ -421,6 +422,13 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Load workspace protection rules on workspace change
|
||||
$effect(() => {
|
||||
const workspace = $workspaceStore
|
||||
if (workspace) {
|
||||
untrack(() => loadProtectionRules(workspace))
|
||||
}
|
||||
})
|
||||
watchOnce(
|
||||
() => globalDbManagerDrawer.val,
|
||||
() => {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
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'
|
||||
|
||||
@@ -106,6 +107,8 @@
|
||||
}
|
||||
})
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
// Check if there's a tutorial parameter in the URL
|
||||
const tutorialParam = $page.url.searchParams.get('tutorial')
|
||||
@@ -114,8 +117,7 @@
|
||||
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)
|
||||
@@ -281,7 +283,7 @@
|
||||
title="Home"
|
||||
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
|
||||
>
|
||||
{#if !$userStore?.operator}
|
||||
{#if !$userStore?.operator && showCreateButtons}
|
||||
<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}
|
||||
@@ -291,6 +293,8 @@
|
||||
|
||||
<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}>
|
||||
@@ -365,7 +369,7 @@
|
||||
</div>
|
||||
|
||||
{#if tab == 'workspace'}
|
||||
<ItemsList bind:filter bind:subtab />
|
||||
<ItemsList bind:filter bind:subtab showEditButtons={showCreateButtons} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
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
|
||||
@@ -246,6 +247,7 @@
|
||||
href: `${base}/flows/add?template=${flow.path}`,
|
||||
variant: 'subtle',
|
||||
unifiedSize: 'md',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
@@ -290,6 +292,7 @@
|
||||
},
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: LayoutDashboard
|
||||
}
|
||||
})
|
||||
@@ -300,7 +303,7 @@
|
||||
href: `${base}/flows/edit/${path}?nodraft=true`,
|
||||
variant: 'accent',
|
||||
unifiedSize: 'md',
|
||||
disabled: !can_write,
|
||||
disabled: !can_write || !showEditButtons,
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
@@ -335,11 +338,14 @@
|
||||
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',
|
||||
@@ -357,7 +363,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (can_write) {
|
||||
if (can_write && showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Deployments',
|
||||
onclick: () => flowHistory?.open(),
|
||||
@@ -433,6 +439,7 @@
|
||||
}
|
||||
}
|
||||
})
|
||||
let showEditButtons = $state(false)
|
||||
let mainButtons = $derived(getMainButtons(flow, args))
|
||||
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
@@ -528,6 +535,9 @@
|
||||
</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,6 +71,7 @@
|
||||
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 }
|
||||
@@ -122,6 +123,8 @@
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
let typeFilter: string | undefined = $state(undefined)
|
||||
|
||||
async function loadResources(): Promise<void> {
|
||||
@@ -727,29 +730,32 @@
|
||||
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
<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 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}
|
||||
</PageHeader>
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v} />
|
||||
<div class="flex justify-between">
|
||||
<Tabs
|
||||
class="w-full"
|
||||
@@ -1012,7 +1018,7 @@
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
disabled: !canWrite,
|
||||
disabled: !canWrite || !showCreateButtons,
|
||||
action: () => {
|
||||
resourceEditor?.initEdit?.(path)
|
||||
}
|
||||
@@ -1030,7 +1036,7 @@
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
disabled: !canWrite,
|
||||
disabled: !canWrite || !showCreateButtons,
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
@@ -1135,6 +1141,7 @@
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!showCreateButtons}
|
||||
btnClasses="border-0"
|
||||
startIcon={{ icon: Trash }}
|
||||
on:click={() => handleDeleteResourceType(name)}
|
||||
@@ -1145,6 +1152,7 @@
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
disabled={!showCreateButtons}
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => startEditResourceType(name)}
|
||||
>
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
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()
|
||||
|
||||
@@ -354,6 +355,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
let showEditButton = $derived(!isRuleActive('DisableDirectDeployment'))
|
||||
|
||||
$effect(() => {
|
||||
job?.id && lastJobId !== job.id && untrack(() => getConcurrencyKey(job))
|
||||
})
|
||||
@@ -641,6 +644,7 @@
|
||||
}}
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
disabled={!showEditButton}
|
||||
size="sm"
|
||||
startIcon={{ icon: Pen }}>Edit</Button
|
||||
>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
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'
|
||||
@@ -325,6 +326,7 @@
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let persistentScriptDrawer: PersistentScriptDrawer | undefined = $state()
|
||||
let showEditButtons = $state(false)
|
||||
|
||||
function getMainButtons(
|
||||
script: Script | undefined,
|
||||
@@ -341,6 +343,7 @@
|
||||
href: `${base}/scripts/add?template=${script.path}`,
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
@@ -388,7 +391,7 @@
|
||||
$importStore = JSON.parse(JSON.stringify(app))
|
||||
await goto('/apps/add?nodraft=true')
|
||||
},
|
||||
|
||||
disabled: !showEditButtons,
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: LayoutDashboard
|
||||
@@ -419,7 +422,7 @@
|
||||
unifiedSize: 'md',
|
||||
startIcon: Pen,
|
||||
variant: 'accent',
|
||||
disabled: !can_write
|
||||
disabled: !can_write || !showEditButtons
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -448,13 +451,15 @@
|
||||
|
||||
const menuItems: any = []
|
||||
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
Icon: FolderOpen,
|
||||
onclick: () => {
|
||||
moveDrawer?.openDrawer(script?.path ?? '', script?.summary, 'script')
|
||||
}
|
||||
})
|
||||
if (showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
Icon: FolderOpen,
|
||||
onclick: () => {
|
||||
moveDrawer?.openDrawer(script?.path ?? '', script?.summary, 'script')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: 'Audit logs',
|
||||
@@ -506,35 +511,37 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (script.archived) {
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: 'Unarchive',
|
||||
Icon: ArchiveRestore,
|
||||
label: 'Delete',
|
||||
Icon: Trash,
|
||||
onclick: async () => {
|
||||
unarchiveScript(script.hash)
|
||||
},
|
||||
color: 'red'
|
||||
})
|
||||
} else {
|
||||
menuItems.push({
|
||||
label: 'Archive',
|
||||
Icon: Archive,
|
||||
onclick: async () => {
|
||||
archiveScript(script.hash)
|
||||
deleteScript(script.hash)
|
||||
},
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: 'Delete',
|
||||
Icon: Trash,
|
||||
onclick: async () => {
|
||||
deleteScript(script.hash)
|
||||
},
|
||||
color: 'red'
|
||||
})
|
||||
|
||||
return menuItems
|
||||
}
|
||||
|
||||
@@ -680,6 +687,9 @@
|
||||
</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 md:min-h-[300px] flex flex-col md:justify-center">
|
||||
<div class="flex flex-col gap-0.5 mb-1">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
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'
|
||||
@@ -45,6 +46,8 @@
|
||||
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()
|
||||
@@ -183,28 +186,31 @@
|
||||
tooltip="Save and permission strings to be reused in Scripts and Flows."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
|
||||
>
|
||||
<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 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}
|
||||
</PageHeader>
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v} />
|
||||
|
||||
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
|
||||
<ContextualVariableEditor
|
||||
@@ -404,7 +410,7 @@
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
action: () => variableEditor?.editVariable(path),
|
||||
disabled: !canWrite
|
||||
disabled: !canWrite || !showCreateButtons
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
@@ -419,7 +425,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled: !owner
|
||||
disabled: !owner || !showCreateButtons
|
||||
},
|
||||
...(isDeployable(
|
||||
is_secret ? 'secret' : 'variable',
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
import WorkspaceDependenciesSettings from '$lib/components/workspaceSettings/WorkspaceDependenciesSettings.svelte'
|
||||
import SettingsFooter from '$lib/components/workspaceSettings/SettingsFooter.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import WorkspaceRulesets from '$lib/components/workspaceSettings/WorkspaceRulesets.svelte'
|
||||
|
||||
let slackInitialPath: string = $state('')
|
||||
let slackScriptPath: string = $state('')
|
||||
@@ -297,6 +298,7 @@
|
||||
| 'native_triggers'
|
||||
| 'encryption'
|
||||
| 'dependencies'
|
||||
| 'rulesets'
|
||||
// Both 'slack' and 'teams' URLs map to 'slack' tab
|
||||
if (selectedTab === 'teams') {
|
||||
return 'slack'
|
||||
@@ -1106,6 +1108,13 @@
|
||||
aiId: 'workspace-settings-deploy-to',
|
||||
aiDescription: 'Deployment UI workspace settings',
|
||||
isEE: true
|
||||
},
|
||||
{
|
||||
id: 'rulesets',
|
||||
label: 'Rulesets',
|
||||
aiId: 'workspace-settings-rulesets',
|
||||
aiDescription: 'Protection Rulesets workspace settings',
|
||||
isEE: true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1259,6 +1268,12 @@
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{:else if tab == 'rulesets'}
|
||||
<SettingsPageHeader
|
||||
title="Workspace Protection Rulesets"
|
||||
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."
|
||||
/>
|
||||
<WorkspaceRulesets />
|
||||
{:else if tab == 'premium'}
|
||||
<PremiumInfo {customer_id} {plan} />
|
||||
{:else if tab == 'slack'}
|
||||
|
||||
Reference in New Issue
Block a user