feat: restriction rulesets for workspaces (#7879)

This commit is contained in:
wendrul
2026-02-10 16:49:25 +00:00
committed by GitHub
parent 25255f9526
commit 2851b6b7ca
46 changed files with 2338 additions and 211 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1
View File
@@ -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)
);
+40 -2
View File
@@ -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)?;
+54 -1
View File
@@ -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!(
+77 -6
View File
@@ -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,
+161 -1
View File
@@ -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:
+72 -1
View File
@@ -49,7 +49,7 @@ use sha2::{Digest, Sha256};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::{types::Uuid, FromRow};
use std::str;
use windmill_audit::audit_oss::audit_log;
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();
+1 -3
View File
@@ -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")]
+202
View File
@@ -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,
+99
View File
@@ -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));
+52
View File
@@ -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>
+11
View File
@@ -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&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
{#if showCreateButtons}
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor?.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew()}
>
New&nbsp;variable
</Button>
{/if}
</div>
{/if}
</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'}