Files
windmill/frontend/src/lib/workspaceProtectionRules.svelte.ts
T
AlexRV12andClaude Opus 5 ef8a8e821c fix: check direct-deployment lock and superadmin in the deploy preflight (#10748)
* fix: check direct-deployment lock and superadmin in the deploy preflight

`checkDeployPermission` mirrors the server's `check_deploy_rules` so the deploy
UI can disable an action with a reason instead of letting the click come back
403. It modelled only `RestrictDeployToDeployers`, leaving two terms out:

- `DisableDirectDeployment` was never evaluated. In a workspace carrying only
  that rule the preflight allowed the deploy and the request 403'd.
- The server bypasses on `ApiAuthed.is_admin`, which is `usr.is_admin ||
  super_admin`, while `whoami` reports the two separately. A superadmin who is
  a plain member of the workspace was refused a deploy the server allows.

Evaluate `DisableDirectDeployment` first, as the server does, so the same
message wins when both rules block, and add the superadmin term to the shared
ruleset bypass helper. `wm_deployers` membership is an implicit pass on
`RestrictDeployToDeployers` alone, so it no longer short-circuits the rules
fetch the way admin does — a deployer is still bound by a direct-deployment
lock, and a test pins that.

The operator refusal stays above the admin/superadmin short-circuit: the server
refuses operators in the item handlers whatever their global role, so a
superadmin who is an operator in the workspace is still refused. Its doc no
longer presents that term as part of the `check_deploy_rules` mirror, since the
rule carries no operator term and refusing every kind here is deliberately
stricter than the server.

Callers no longer name which rules the preflight covers. That list rots at every
site that repeats it, so it lives only at the preflight itself.

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

* fix: apply the direct-deployment refusal only to the kinds the server gates

`check_deploy_rules` runs from the item handlers, and only scripts, flows, apps,
resources, resource types, variables and folders reach it. Schedules and
triggers hit no gate at all: in a `DisableDirectDeployment` workspace the server
returns 200 for a schedule and 403 for a script.

The preflight answers per workspace, and that one answer disabled the deploy
action for every kind, so adding the direct-deployment term would have blocked
schedule and trigger deploys the server accepts. Tag each refusal with the term
that produced it and let callers narrow a direct-deployment refusal to the kinds
the server actually gates; a selection still blocks as soon as one gated kind is
in it.

The deployers-only term keeps applying to every kind. It over-reaches the same
way, but narrowing it would loosen the UI beyond mirroring the new rule, so it
stays as it is and no existing behaviour changes.

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

* fix: mirror the superadmin bypass in the per-item deploy checks too

`checkPathWritePermission` and `canPreserveOnBehalfOf` still tested `is_admin`
alone. The server reads the merged `ApiAuthed.is_admin` in both places —
`is_owner` for path ownership and `can_preserve_on_behalf_of` for the deploy
identity — so a superadmin who is a plain member was refused a write the server
accepts: creating a script in a folder owned by someone else returns 201 for
them.

Also drop the rule enumeration from the session deploy guard's comment, which
named the operator and deployer rules for a preflight that now covers the
direct-deployment lock and answers per kind.

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

* fix: keep the deploy refusal on an empty selection and match the advice to the fork lock

* fix: mirror the superadmin bypass in the compare page's on-behalf-of gate

* docs: name the variable that tracks the deploy direction

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:01:46 +02:00

248 lines
7.6 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[]> {
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 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))
}