Files
windmill/frontend/src/lib/workspaceProtectionRules.svelte.ts
T
hugocasaandClaude Opus 4.8 ebe31aeeac feat(dev-workspace): reflect existing protection rules in lock toggles (#10093)
* feat(dev-workspace): reflect existing protection rules in lock toggles

When creating or attaching a dev workspace, the "block direct edits" and
"prevent forking" toggles now check the root workspace's current protection
rules. If a restriction is already enforced by an existing rule, its toggle is
shown on but locked, with a note, instead of offering a fresh default that could
misrepresent the effect. The value sent to the backend is derived so it stays
consistent with what the locked toggle shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: clarify fail-open comment on dev-workspace lock toggles

Reword the protection-rule fetch comment so the fallback path isn't misread as
dropping protection: a failed fetch falls back to the editable default-on
toggle, and any real rule still enforces server-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dev-workspace): lock protection toggles until rules load

The lock toggles derived alreadyBlocks* from an async fetch, so during the load
window (and the first frame before loading flips) they were editable and the
effective value could be false. A user could turn a lock off and submit before
an existing rule was detected, omitting the reserved rule and silently leaving
prod unprotected once that existing rule was later removed.

Treat "rules not yet known" (loading || current === undefined) the same as
"already enforced": lock the toggle on and keep the effective value true during
that window, so the request can never submit false before the fetch resolves.
Submission stays available (a hung fetch degrades to over-protection, not a
blocked form). Also fixes the stale-value flash when switching base workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dev-workspace): honor rule bypasses and guard stale protection fetches

Two issues in the protection-rule awareness for the dev-workspace lock toggles:

- Bypassable rules became unconditional locks. alreadyBlocks* used
  isRuleActiveInRulesets, which ignores bypass_users/bypass_groups, and forced
  the request flag to true. The reserved dev_workspace_lock rule is created with
  empty bypass lists, so layering it over an existing rule that let specific
  users through revoked their deploy/forking access. Switch to
  isRuleUnconditionallyActiveInRulesets so a toggle is only shown as already
  enforced (locked) when an existing rule has no bypasses; a bypassable rule
  stays editable, making the lock the user's explicit choice.

- A stale protection fetch could apply another base's rules. The generated
  client can't take an abort signal, so a delayed response for a previous base
  could overwrite the newly selected one. Tag each result with its workspace and
  only trust a result matching the current base; also throw AbortError from a
  superseded fetch so it can't overwrite current.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: condense protection helper comment to four lines

Trim the isRuleUnconditionallyActiveInRulesets doc comment to satisfy the
AGENTS.md ≤4-line comment rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dev-workspace): align already-enforced note under the toggle label

The note used ml-8, landing under the toggle switch rather than aligned with
the switch edge or the label, so it read as floating. Bump to ml-11 so it lines
up under the label as helper text for that toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:01:35 +02:00

242 lines
7.1 KiB
TypeScript

import { WorkspaceService, type ProtectionRuleset, type ProtectionRuleKind } from './gen'
import type { UserExt } from './stores'
// The slice of the user identity the bypass checks read — structural, so
// callers can pass a whoami response (normalised groups) as well as the
// UserExt store value.
export type RuleBypassUser = Pick<UserExt, 'is_admin' | 'username' | 'groups'>
/**
* 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: RuleBypassUser): 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: RuleBypassUser | 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))
}
/**
* Whether a rule kind is enforced with no bypass users/groups in at least one ruleset, the only case
* that matches the empty-bypass reserved dev-workspace lock. A bypassable rule does not, since adding
* the unconditional lock would revoke those users' access; callers keep such a toggle editable.
*/
export function isRuleUnconditionallyActiveInRulesets(
rulesets: ProtectionRuleset[],
ruleKind: ProtectionRuleKind
): boolean {
return rulesets.some(
(ruleset) =>
ruleset.rules.includes(ruleKind) &&
ruleset.bypass_users.length === 0 &&
ruleset.bypass_groups.length === 0
)
}
/**
* 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: RuleBypassUser | 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))
}