mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
fix: let admins edit the dev workspace lock ruleset (#10512)
* fix: let admins edit the dev workspace lock ruleset Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: route the empty protections panel through the owning workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: make protection rule rename actually apply Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: cache the renamed protection rule query for sqlx offline Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep verbatim rule names and scope parent-admin lookup to its workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: store renamed protection rule names verbatim Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_protection_rule\n SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4\n WHERE workspace_id = $5 AND name = $6\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "adbe6ce27d46ba0d2d3ce87d875f60c179e5bccc54a36c8d5dcf04fd84445309"
|
||||
}
|
||||
@@ -421,3 +421,268 @@ async fn test_restrict_deploy_to_deployers(db: Pool<Postgres>) -> anyhow::Result
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The dev-workspace pairing owns `dev_workspace_lock` by name: attaching creates it, detaching
|
||||
/// deletes it. Only those two ends of the name are reserved. Updating the rule has to stay open,
|
||||
/// since relaxing a restriction from the rulesets UI is the only way an admin can loosen a pairing's
|
||||
/// lock without detaching the dev workspace outright.
|
||||
///
|
||||
/// Reads the row directly rather than through `list_protection_rules`, which goes via the
|
||||
/// process-wide PROTECTION_RULES_CACHE that other tests in this file share.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_dev_workspace_lock_rule_reservation(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
let name = windmill_common::workspaces::DEV_WORKSPACE_LOCK_RULE_NAME;
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": name,
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"creating the reserved rule by hand should be refused"
|
||||
);
|
||||
|
||||
// Stands in for an attach, which is what really creates the rule. 3 = DisableDirectDeployment |
|
||||
// DisableWorkspaceForking, the pairing's default lock.
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
|
||||
VALUES ('test-workspace', $1, 3, '{}', '{}')",
|
||||
)
|
||||
.bind(name)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/{name}")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"rules": ["DisableWorkspaceForking"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"an admin must be able to drop a restriction from the reserved rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let rules: i32 = sqlx::query_scalar(
|
||||
"SELECT rules FROM workspace_protection_rule WHERE workspace_id = 'test-workspace' AND name = $1",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(rules, 2, "only DisableWorkspaceForking should remain");
|
||||
|
||||
// Renaming it away would strand the pairing's lock, which is located by name.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/{name}")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": "renamed-lock",
|
||||
"rules": ["DisableWorkspaceForking"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"renaming the reserved rule should be refused"
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().delete(format!("{base}/workspaces/protection_rules/{name}")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"deleting the reserved rule should stay refused: detaching the dev workspace removes it"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The name is half the row's primary key, so a rename has to move the row: the update applies the
|
||||
/// name from the body, not just the one in the path. Collisions and the reserved name are refused,
|
||||
/// and a name submitted unchanged is left exactly as stored.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_protection_rule_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
for name in ["before-rename", "occupied"] {
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": name,
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "setup: create '{}'", name);
|
||||
}
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/before-rename")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": "after-rename",
|
||||
"rules": ["DisableWorkspaceForking"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"rename should succeed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let names: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT name FROM workspace_protection_rule WHERE workspace_id = 'test-workspace' ORDER BY name",
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["after-rename".to_string(), "occupied".to_string()],
|
||||
"the row should have moved to the new name, not been duplicated"
|
||||
);
|
||||
|
||||
for (target, why) in [
|
||||
(
|
||||
"occupied",
|
||||
"renaming onto an existing rule should be refused",
|
||||
),
|
||||
(
|
||||
windmill_common::workspaces::DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
"renaming onto the reserved name should be refused",
|
||||
),
|
||||
] {
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/after-rename")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": target,
|
||||
"rules": ["DisableWorkspaceForking"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "{}", why);
|
||||
}
|
||||
|
||||
// Names are stored verbatim and the editor submits the current name on every save, so a padded
|
||||
// name has to survive a restrictions-only edit rather than being trimmed into a rename.
|
||||
let padded = " padded-name ";
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": padded,
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "setup: create the padded rule");
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{base}/workspaces/protection_rules/%20padded-name%20"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": padded,
|
||||
"rules": ["DisableWorkspaceForking"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"restrictions-only edit of a padded name should succeed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let still_there: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = 'test-workspace' AND name = $1)",
|
||||
)
|
||||
.bind(padded)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
still_there,
|
||||
"the padded name must not have been trimmed into a rename"
|
||||
);
|
||||
|
||||
// The mirror case: a name differing only in surrounding whitespace is a real rename, applied
|
||||
// verbatim rather than collapsing into a success that changed nothing.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/after-rename")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": " after-rename ",
|
||||
"rules": ["DisableWorkspaceForking"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"whitespace-only rename should apply: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
let renamed: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = 'test-workspace' AND name = ' after-rename ')",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(renamed, "the padded form should now be the stored name");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8770,6 +8770,9 @@ struct CreateProtectionRuleRequest {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateProtectionRuleRequest {
|
||||
/// Renames the rule when it differs from the path name. Absent means "leave the name alone",
|
||||
/// which is what a client that never sends the field gets.
|
||||
name: Option<String>,
|
||||
rules: Vec<ProtectionRuleKind>,
|
||||
bypass_groups: Vec<String>,
|
||||
bypass_users: Vec<String>,
|
||||
@@ -8917,10 +8920,11 @@ async fn ensure_dev_parent_is_root(db: &DB, parent_w_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `dev_workspace_lock` is owned by the dev-workspace feature (attach/detach/archive/delete create and
|
||||
/// remove it by name). Reserve it from the public protection-rule API so a user-managed rule can't
|
||||
/// collide: otherwise the feature's name-based cleanup would clobber the user's rule, or a manual edit
|
||||
/// could weaken the feature's lock.
|
||||
/// `dev_workspace_lock` is owned by the dev-workspace feature, which creates and removes it by name
|
||||
/// (attach/detach/archive/delete). Reserve the name against creation and deletion so a user-managed
|
||||
/// rule can't collide with the feature's name-based cleanup, and so detach stays the way a pairing's
|
||||
/// lock is lifted. Updating it is deliberately allowed: relaxing the lock is an admin's call, and the
|
||||
/// name is immutable on update so no collision can arise.
|
||||
fn reject_reserved_rule_name(name: &str) -> Result<()> {
|
||||
if name == DEV_WORKSPACE_LOCK_RULE_NAME {
|
||||
return Err(Error::BadRequest(format!(
|
||||
@@ -9014,7 +9018,28 @@ async fn update_protection_rule(
|
||||
Json(req): Json<UpdateProtectionRuleRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
reject_reserved_rule_name(&rule_name)?;
|
||||
|
||||
// A rename moves the row's primary key, so it needs the same name checks a create does. The
|
||||
// reserved rule can be neither end of one: the dev-workspace feature finds it by name, so
|
||||
// renaming it away would strand the lock and renaming onto it would collide with the feature.
|
||||
// Names are stored verbatim, as creation stores them, so this comparison is raw: a rule called
|
||||
// " prod-lock " survives an edit that submits its current name back untouched, and a name that
|
||||
// differs only in surrounding whitespace is a real rename rather than a silent no-op.
|
||||
let new_name = req.name.as_deref().filter(|n| *n != rule_name);
|
||||
if let Some(new_name) = new_name {
|
||||
if new_name.trim().is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"Protection rule name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if rule_name == DEV_WORKSPACE_LOCK_RULE_NAME {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{}' cannot be renamed: the dev workspace feature locates it by name",
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
)));
|
||||
}
|
||||
reject_reserved_rule_name(new_name)?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -9035,13 +9060,33 @@ async fn update_protection_rule(
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(new_name) = new_name {
|
||||
let taken = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||||
&w_id,
|
||||
new_name
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if taken {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Protection rule with name '{}' already exists",
|
||||
new_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let final_name = new_name.unwrap_or(&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
|
||||
SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4
|
||||
WHERE workspace_id = $5 AND name = $6
|
||||
"#,
|
||||
final_name,
|
||||
ProtectionRules::from(&req.rules).bits(),
|
||||
&req.bypass_groups,
|
||||
&req.bypass_users,
|
||||
@@ -9051,14 +9096,18 @@ async fn update_protection_rule(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let mut audit_args = std::collections::HashMap::from([("name", final_name)]);
|
||||
if new_name.is_some() {
|
||||
audit_args.insert("previous_name", &rule_name[..]);
|
||||
}
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.update_protection_rule",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&rule_name),
|
||||
Some([("name", &rule_name[..])].into()),
|
||||
Some(final_name),
|
||||
Some(audit_args),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -9072,14 +9121,14 @@ async fn update_protection_rule(
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", final_name) },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Updated protection rule '{}'", rule_name))
|
||||
Ok(format!("Updated protection rule '{}'", final_name))
|
||||
}
|
||||
|
||||
/// Delete a protection rule
|
||||
|
||||
@@ -6657,6 +6657,12 @@ paths:
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: >-
|
||||
New name for the rule. Omit, or pass the current name, to leave it unchanged.
|
||||
The reserved `dev_workspace_lock` rule cannot be renamed, nor can another rule
|
||||
be renamed onto it.
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import {
|
||||
loadProtectionRules,
|
||||
isRuleActiveInRulesets,
|
||||
isRuleUnconditionallyActiveInRulesets
|
||||
isRuleUnconditionallyActiveInRulesets,
|
||||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { GitFork, ExternalLink, Check, Minus } from 'lucide-svelte'
|
||||
import { GitFork, ExternalLink, Check, Minus, Pen } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
|
||||
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
@@ -59,13 +61,18 @@
|
||||
let attachLabel = $state<'dev' | 'staging'>('dev')
|
||||
let busy = $state(false)
|
||||
|
||||
// The pairing's locks always sit on the root (prod) workspace: this one when viewed from prod, the
|
||||
// parent's when viewed from inside the dev workspace. A plain fork has no pairing of its own, so it
|
||||
// reads nothing.
|
||||
let lockWorkspace = $derived(parentId ? (isDev ? parentId : undefined) : $workspaceStore)
|
||||
|
||||
// If this workspace already blocks direct deploy / forking through an existing protection rule, keep
|
||||
// the matching lock toggle on but locked: attaching only manages its own reserved dev-workspace rule,
|
||||
// so turning it "off" here couldn't lift a separately-defined block. A failed fetch falls back to the
|
||||
// editable default-on toggle (real rules still enforce). Once paired, the same rules report which
|
||||
// locks are actually in force.
|
||||
const rootProtectionRules = resource(
|
||||
() => (!parentId ? $workspaceStore : undefined),
|
||||
() => lockWorkspace,
|
||||
async (ws, _prev, { signal }) => {
|
||||
if (!ws) return undefined
|
||||
// `fetchProtectionRulesForWorkspace` fails open with an empty list, which the toggles below
|
||||
@@ -82,11 +89,12 @@
|
||||
return { ws, rules: rules ?? [], failed: rules === undefined }
|
||||
}
|
||||
)
|
||||
// Only trust a result that belongs to the current workspace (guards the in-flight window and any
|
||||
// out-of-order response); undefined means "not known yet" and is treated as locked below.
|
||||
// Only trust a result that belongs to the workspace we're currently reading rules for (guards the
|
||||
// in-flight window and any out-of-order response); undefined means "not known yet" and is treated as
|
||||
// locked below.
|
||||
let rootResult = $derived.by(() => {
|
||||
const current = rootProtectionRules.current
|
||||
return current && current.ws === $workspaceStore ? current : undefined
|
||||
return current && current.ws === lockWorkspace ? current : undefined
|
||||
})
|
||||
let rootRules = $derived(rootResult?.rules)
|
||||
// Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a
|
||||
@@ -123,6 +131,51 @@
|
||||
let enforcementReadFailed = $derived(rootResult?.failed ?? false)
|
||||
let enforcementUnknown = $derived(rulesUnknown || enforcementReadFailed)
|
||||
|
||||
// The named rulesets actually carrying either lock. Reporting only "blocked / allowed" left no way
|
||||
// to reach the rule that decides it, which is the one thing a reader here wants to change.
|
||||
let enforcingRulesets = $derived(
|
||||
(rootRules ?? []).filter(
|
||||
(r) =>
|
||||
r.rules.includes('DisableDirectDeployment') || r.rules.includes('DisableWorkspaceForking')
|
||||
)
|
||||
)
|
||||
// Editing prod's rules from the dev side needs admin IN PROD, which membership does not imply and
|
||||
// this workspace's own admin rights say nothing about: the rulesets tab is admin-only, so a link
|
||||
// offered to anyone else lands them on a tab they cannot open. Asked of the parent directly, as
|
||||
// `is_admin` is per-workspace. A superadmin is admin everywhere and has no `usr` row to find.
|
||||
// Tagged with its workspace and guarded against a superseded response, like the rules resource
|
||||
// above: runed keeps the previous `current` while a new source loads, so switching between dev
|
||||
// workspaces would otherwise offer Edit based on the previous parent's role.
|
||||
const parentUser = resource(
|
||||
() => (isDev && parentId ? parentId : undefined),
|
||||
async (ws, _prev, { signal }) => {
|
||||
if (!ws) return undefined
|
||||
const user = await getUserExt(ws)
|
||||
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
|
||||
return { ws, isAdmin: user?.is_admin === true }
|
||||
}
|
||||
)
|
||||
let canEditParentRules = $derived(
|
||||
$superadmin || (parentUser.current?.ws === parentId && parentUser.current?.isAdmin === true)
|
||||
)
|
||||
|
||||
// With a name, deep-links into that rule's drawer; without one, the rulesets list. Built from
|
||||
// scratch rather than from the current query so no stale `?workspace=<dev>` survives a switch.
|
||||
function rulesetsHref(name?: string): string {
|
||||
const rule = name ? `&rule=${encodeURIComponent(name)}` : ''
|
||||
return `${base}/workspace_settings?tab=rulesets${rule}`
|
||||
}
|
||||
|
||||
function openRulesets(name?: string) {
|
||||
goto(rulesetsHref(name))
|
||||
}
|
||||
|
||||
function openRulesetsInParent(name?: string) {
|
||||
if (!parentId) return
|
||||
switchWorkspace(parentId)
|
||||
goto(rulesetsHref(name))
|
||||
}
|
||||
|
||||
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
|
||||
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
|
||||
// prod), so it's excluded here.
|
||||
@@ -205,6 +258,80 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- The locks are protection rules, so being paired does not imply them: a pairing that came from the
|
||||
deploy_to migration rather than from an attach carries neither. `onOpen` navigates to the rules
|
||||
of the workspace this panel describes, which is not the active one on the dev side; it is
|
||||
undefined when the reader is not an admin there. The labels name that workspace in that case,
|
||||
so the button does not switch workspaces without saying so. -->
|
||||
{#snippet protectionsPanel(opts: {
|
||||
title: string
|
||||
onOpen?: (name?: string) => void
|
||||
editLabel: string
|
||||
manageLabel: string
|
||||
})}
|
||||
{@const onOpen = opts.onOpen}
|
||||
<div class="flex flex-col gap-1 rounded-md border bg-surface-secondary p-3">
|
||||
<span class="text-xs font-semibold text-emphasis">{opts.title}</span>
|
||||
{#if enforcementUnknown}
|
||||
<span class="text-2xs text-secondary">
|
||||
{enforcementReadFailed
|
||||
? 'Could not read the protection rules'
|
||||
: 'Checking protection rules…'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-2xs text-secondary flex items-center gap-1.5">
|
||||
{#if enforcesDeployBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
size={12}
|
||||
/>{/if}
|
||||
Direct edits {enforcesDeployBlock ? 'are blocked' : 'are allowed'}
|
||||
</span>
|
||||
<span class="text-2xs text-secondary flex items-center gap-1.5">
|
||||
{#if enforcesForkingBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
size={12}
|
||||
/>{/if}
|
||||
Forking {enforcesForkingBlock ? 'is blocked' : 'is allowed'}
|
||||
</span>
|
||||
{#if enforcesDeployBlock || enforcesForkingBlock}
|
||||
<!-- Only admins reach this tab, and `check_user_against_rule` lets an admin through
|
||||
every rule, so without this the reader would try what the panel calls blocked. -->
|
||||
<span class="text-2xs text-secondary">Workspace admins always bypass these rules.</span>
|
||||
{/if}
|
||||
{#if enforcingRulesets.length > 0}
|
||||
<div class="flex flex-col gap-1 mt-2 pt-2 border-t">
|
||||
<span class="text-2xs text-secondary">Enforced by</span>
|
||||
{#each enforcingRulesets as ruleset (ruleset.name)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-2xs font-mono text-emphasis truncate">{ruleset.name}</span>
|
||||
{#if ruleset.name === DEV_WORKSPACE_LOCK_RULE_NAME}
|
||||
<span class="text-2xs text-secondary">Applied by this pairing</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if onOpen}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
startIcon={{ icon: Pen }}
|
||||
onclick={() => onOpen(ruleset.name)}
|
||||
>
|
||||
{opts.editLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if onOpen && enforcingRulesets.length === 0}
|
||||
<div class="self-start mt-1">
|
||||
<Button variant="subtle" unifiedSize="2xs" onclick={() => onOpen()}>
|
||||
{opts.manageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if isDev && parentId}
|
||||
<div class="flex flex-col gap-3 max-w-2xl">
|
||||
<p class="text-sm">
|
||||
@@ -218,6 +345,16 @@
|
||||
<span class="font-mono">{currentLabel}</span> branch.
|
||||
</span>
|
||||
</div>
|
||||
<!-- A reader who is not a member of the parent gets a 403 listing its rules, which is expected
|
||||
here rather than an anomaly worth a permanent error box, so drop the panel instead. -->
|
||||
{#if !enforcementReadFailed}
|
||||
{@render protectionsPanel({
|
||||
title: `Protections in force on ${parentId}`,
|
||||
onOpen: canEditParentRules ? openRulesetsInParent : undefined,
|
||||
editLabel: `Edit in ${parentId}`,
|
||||
manageLabel: `Manage in ${parentId}`
|
||||
})}
|
||||
{/if}
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -234,46 +371,12 @@
|
||||
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b> ({pairedDev.id}).
|
||||
Edits to this workspace are redirected there.
|
||||
</p>
|
||||
<!-- The locks are protection rules, so being paired does not imply them: a pairing that came
|
||||
from the deploy_to migration rather than from an attach carries neither. -->
|
||||
<div class="flex flex-col gap-1 rounded-md border bg-surface-secondary p-3">
|
||||
<span class="text-xs font-semibold text-emphasis">Protections in force on this workspace</span
|
||||
>
|
||||
{#if enforcementUnknown}
|
||||
<span class="text-2xs text-secondary">
|
||||
{enforcementReadFailed
|
||||
? 'Could not read this workspace’s protection rules'
|
||||
: 'Checking protection rules…'}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-2xs text-secondary flex items-center gap-1.5">
|
||||
{#if enforcesDeployBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
size={12}
|
||||
/>{/if}
|
||||
Direct edits {enforcesDeployBlock ? 'are blocked' : 'are allowed'}
|
||||
</span>
|
||||
<span class="text-2xs text-secondary flex items-center gap-1.5">
|
||||
{#if enforcesForkingBlock}<Check size={12} class="text-green-600" />{:else}<Minus
|
||||
size={12}
|
||||
/>{/if}
|
||||
Forking {enforcesForkingBlock ? 'is blocked' : 'is allowed'}
|
||||
</span>
|
||||
{#if enforcesDeployBlock || enforcesForkingBlock}
|
||||
<!-- Only admins reach this tab, and `check_user_against_rule` lets an admin through
|
||||
every rule, so without this the reader would try what the panel calls blocked. -->
|
||||
<span class="text-2xs text-secondary">Workspace admins always bypass these rules.</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="self-start">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="2xs"
|
||||
onclick={() => goto(`${base}/workspace_settings?tab=rulesets`)}
|
||||
>
|
||||
Manage in Rulesets
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{@render protectionsPanel({
|
||||
title: 'Protections in force on this workspace',
|
||||
onOpen: openRulesets,
|
||||
editLabel: 'Edit',
|
||||
manageLabel: 'Manage in Rulesets'
|
||||
})}
|
||||
<div class="flex gap-2">
|
||||
{#if pairedDev.isMember || $superadmin}
|
||||
<Button
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
} from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clone } from '$lib/utils'
|
||||
import { DEV_WORKSPACE_LOCK_RULE_NAME } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { Save, X, Plus } from 'lucide-svelte'
|
||||
import { safeSelectItems } from '$lib/components/select/utils.svelte'
|
||||
@@ -28,6 +29,9 @@
|
||||
|
||||
// Create mode vs Edit mode
|
||||
const isCreateMode = $derived(!rule)
|
||||
// The dev-workspace feature creates, finds and removes its rule by name, so this one name is
|
||||
// fixed. Its restrictions and bypassers stay editable.
|
||||
const isNameLocked = $derived(rule?.name === DEV_WORKSPACE_LOCK_RULE_NAME)
|
||||
|
||||
// Helper function to check if a rule is in the array
|
||||
const hasRule = (ruleKind: string) => rule?.rules?.includes(ruleKind as any) ?? false
|
||||
@@ -190,6 +194,7 @@
|
||||
workspace: $workspaceStore,
|
||||
ruleName: initialName,
|
||||
requestBody: {
|
||||
name,
|
||||
rules: [
|
||||
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
|
||||
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
|
||||
@@ -235,10 +240,16 @@
|
||||
bind:value={name}
|
||||
error={nameError}
|
||||
inputProps={{
|
||||
placeholder: 'Enter rule name'
|
||||
placeholder: 'Enter rule name',
|
||||
disabled: isNameLocked
|
||||
}}
|
||||
/>
|
||||
{#if nameError}
|
||||
{#if isNameLocked}
|
||||
<div class="text-xs text-secondary">
|
||||
Managed by the dev workspace pairing, which locates this rule by name. Its restrictions and
|
||||
bypassers below can still be changed.
|
||||
</div>
|
||||
{:else if nameError}
|
||||
<div class="text-xs text-red-600">{nameError}</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
@@ -11,20 +11,29 @@
|
||||
import { Plus, Pen, Trash } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { WorkspaceService, type ProtectionRuleset } from '$lib/gen'
|
||||
import { DEV_WORKSPACE_LOCK_RULE_NAME } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
let rules: ProtectionRuleset[] | undefined = $state<ProtectionRuleset[] | undefined>(undefined)
|
||||
let selectedRule: ProtectionRuleset | undefined = $state(undefined)
|
||||
let ruleDrawer: Drawer | undefined = $state(undefined)
|
||||
|
||||
// A failed load still yields an empty list so the table renders, so the deep link below has to be
|
||||
// told apart from a genuinely absent rule.
|
||||
let loadFailed = $state(false)
|
||||
|
||||
async function loadRules() {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
try {
|
||||
rules = await WorkspaceService.listProtectionRules({ workspace: $workspaceStore })
|
||||
loadFailed = false
|
||||
} catch (error) {
|
||||
console.error('Failed to load protection rules:', error)
|
||||
sendUserToast('Failed to load protection rules', true)
|
||||
rules = []
|
||||
loadFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +43,28 @@
|
||||
}
|
||||
})
|
||||
|
||||
// `?rule=<name>` deep-links straight into a rule's drawer, so the dev-workspace panel can point at
|
||||
// the ruleset enforcing its locks instead of dropping the reader on the list. The param is consumed
|
||||
// on open: leaving it set would re-open the drawer on every later save or tab switch, since the
|
||||
// sidebar carries the whole query string across tabs.
|
||||
$effect(() => {
|
||||
const name = $page.url.searchParams.get('rule')
|
||||
const loaded = rules
|
||||
if (!name || !loaded) return
|
||||
untrack(() => {
|
||||
const match = loaded.find((r) => r.name === name)
|
||||
if (match) {
|
||||
selectedRule = match
|
||||
ruleDrawer?.openDrawer()
|
||||
} else if (!loadFailed) {
|
||||
sendUserToast(`Protection rule '${name}' not found in this workspace`, true)
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.delete('rule')
|
||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true })
|
||||
})
|
||||
})
|
||||
|
||||
async function deleteRule(name: string) {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
@@ -145,6 +176,11 @@
|
||||
<Cell first>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-emphasis text-xs font-semibold">{rule.name}</span>
|
||||
{#if rule.name === DEV_WORKSPACE_LOCK_RULE_NAME}
|
||||
<span class="text-2xs text-secondary">
|
||||
Applied by the dev workspace pairing. Detach the dev workspace to remove it.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
@@ -169,14 +205,20 @@
|
||||
ruleDrawer?.openDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: async () => {
|
||||
await deleteRule(rule.name)
|
||||
}
|
||||
}
|
||||
// The reserved rule is removed by detaching the dev workspace; the API refuses
|
||||
// to delete it by name, so offering the action here could only ever fail.
|
||||
...(rule.name === DEV_WORKSPACE_LOCK_RULE_NAME
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
type: 'delete' as const,
|
||||
action: async () => {
|
||||
await deleteRule(rule.name)
|
||||
}
|
||||
}
|
||||
])
|
||||
]}
|
||||
/>
|
||||
</Cell>
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { UserExt } from './stores'
|
||||
// UserExt store value.
|
||||
export type RuleBypassUser = Pick<UserExt, 'is_admin' | 'username' | 'groups'>
|
||||
|
||||
// Mirrors DEV_WORKSPACE_LOCK_RULE_NAME in windmill-common. The pairing owns this rule by name:
|
||||
// attaching a dev workspace creates it, detaching deletes it. The API refuses to create or delete
|
||||
// it, so the UI must not offer those two actions on it; its restrictions stay editable.
|
||||
export const DEV_WORKSPACE_LOCK_RULE_NAME = 'dev_workspace_lock'
|
||||
|
||||
/**
|
||||
* Internal reactive state using Svelte 5 $state rune
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user