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:
Ruben Fiszel
2026-08-04 16:25:59 +00:00
committed by GitHub
parent f3e73fb006
commit ecae9320d0
8 changed files with 567 additions and 67 deletions
@@ -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"
}
+265
View File
@@ -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
+6
View File
@@ -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 workspaces 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
*/