Files
windmill/frontend/src/lib/workspaceProtectionRules.svelte.ts
T
d6c2b2c683 feat: add a use/plan/build mode picker and read-only prod sessions (#11940)
* feat: read-only ai sessions on protected workspaces with a use, plan and build picker

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

* fix: address local review findings on the use, plan and build picker

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

* fix: address CI review findings on use mode and protected sessions

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

* fix: hold test-run forms and pending protection lookups to use mode

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

* refactor: fetch rules once per send and dedupe helpers and prompt text

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

* fix: send role-locked operators to an admin and cover it with an eval

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

* feat: count the work mode each session message runs in

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

* chore: regenerate the mcp session definitions after merging main

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

* feat: rename build to use & build and let the chat ask to switch modes

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

* fix: word a declined mode switch by the switch, not the mode

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

* chore: update ee-repo-ref to 55445977dd05c25791388070182c77f4dbc20c0e

This commit updates the EE repository reference after PR #915 was merged in windmill-ee-private.

Previous ee-repo-ref: 7f7bf7003e356346ce74395af02c5bdf14f38781

New ee-repo-ref: 55445977dd05c25791388070182c77f4dbc20c0e

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-23 15:53:04 +02:00

255 lines
7.9 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' | 'is_super_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
*/
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[]> {
return (await readProtectionRulesForWorkspace(workspace)) ?? []
}
/** The same rules, but `undefined` when they cannot be read, for a caller that has to fail
* closed rather than read an error as "no rules". */
export async function readProtectionRulesForWorkspace(
workspace: string
): Promise<ProtectionRuleset[] | undefined> {
try {
return await WorkspaceService.listProtectionRules({ workspace })
} catch (error) {
console.error(`Failed to fetch protection rules for workspace ${workspace}:`, error)
return undefined
}
}
/**
* 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 or superadmin, in bypass_users, or has group in bypass_groups)
*/
export function canUserBypassRule(ruleset: ProtectionRuleset, userInfo: RuleBypassUser): boolean {
// The server bypasses on `ApiAuthed.is_admin`, which it builds as `usr.is_admin ||
// super_admin`, so testing `is_admin` alone understates a superadmin who is a plain member.
if (userInfo.is_admin || userInfo.is_super_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))
}