mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
* 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>
248 lines
7.6 KiB
TypeScript
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))
|
|
}
|