mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
* 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>
242 lines
7.1 KiB
TypeScript
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))
|
|
}
|