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>
This commit is contained in:
AlexRV12
2026-08-19 12:01:46 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 0cba084a2e
commit ef8a8e821c
8 changed files with 374 additions and 56 deletions
@@ -22,7 +22,11 @@
discardDraft,
draftBaseIsStale
} from '$lib/utils_draft_deploy'
import { checkDeployPermission, type DeployPermission } from '$lib/utils_workspace_deploy'
import {
checkDeployPermission,
deployPermissionForKinds,
type DeployPermission
} from '$lib/utils_workspace_deploy'
import {
type DraftItem,
invalidateWorkspaceDrafts,
@@ -304,20 +308,30 @@
let selectedItems = $state<string[]>([])
let deploying = $state(false)
// Whether the user may deploy drafts into this workspace — fills the
// `RestrictDeployToDeployers` (+ operator) gap via the shared util, same as the
// fork compare page and the session review drawer. Fail-open while resolving.
let deployPerm = $state<DeployPermission>({ ok: true })
// Whether the user may deploy drafts into this workspace, via the shared util —
// same as the fork compare page and the session review drawer. Fail-open while
// resolving.
let workspaceDeployPerm = $state<DeployPermission>({ ok: true })
$effect(() => {
const ws = currentWorkspaceId
// Reset to fail-open on workspace change, and drop a stale resolution —
// otherwise the previous workspace's verdict lingers (or lands last) and
// gates the wrong workspace.
deployPerm = { ok: true }
workspaceDeployPerm = { ok: true }
void checkDeployPermission(ws).then((p) => {
if (ws === currentWorkspaceId) deployPerm = p
if (ws === currentWorkspaceId) workspaceDeployPerm = p
})
})
// A direct-deployment lock never reaches trigger or schedule drafts server-side, so it must
// not disable a selection made only of those. One refused kind still blocks the whole action.
let deployPerm = $derived(
deployPermissionForKinds(
workspaceDeployPerm,
visibleItems
.filter((i) => selectedItems.includes(i.key) && isDeployable(i))
.map((i) => i.draftKind)
)
)
// Select all on the first non-empty load (acting on everything is the common
// intent); only once, so a refetch after a deploy doesn't re-select the
// leftovers.
@@ -41,6 +41,7 @@
import type { Kind } from '$lib/utils_deployable'
import {
checkDeployPermission,
deployPermissionForKinds,
deployItem,
deleteItemInWorkspace,
diffActionableInDirection,
@@ -950,21 +951,28 @@
toggleDeploymentDirection(v)
}
// Fetch user permissions for both workspaces
// Fetch user permissions for both workspaces. The server's `can_preserve_on_behalf_of` reads
// the merged `is_admin || super_admin`, which `whoami` reports as two fields.
$effect(() => {
;[currentWorkspaceId, parentWorkspaceId]
async function fetchPermissions() {
try {
const parentUser = await UserService.whoami({ workspace: parentWorkspaceId })
canPreserveInParent =
parentUser.is_admin || parentUser.groups?.includes('wm_deployers') || false
parentUser.is_admin ||
parentUser.is_super_admin ||
parentUser.groups?.includes('wm_deployers') ||
false
} catch {
canPreserveInParent = false
}
try {
const currentUser = await UserService.whoami({ workspace: currentWorkspaceId })
canPreserveInCurrent =
currentUser.is_admin || currentUser.groups?.includes('wm_deployers') || false
currentUser.is_admin ||
currentUser.is_super_admin ||
currentUser.groups?.includes('wm_deployers') ||
false
} catch {
canPreserveInCurrent = false
}
@@ -972,10 +980,9 @@
fetchPermissions()
})
// Can the user actually deploy into the target workspace? Fills the frontend
// gap for the `RestrictDeployToDeployers` rule (+ operator), shared with the
// session review drawer via the same checkDeployPermission util. Cached per
// workspace; `deployPerm` tracks whichever side the current direction targets.
// Can the user actually deploy into the target workspace? Shared with the session
// review drawer via the same checkDeployPermission util. Cached per workspace;
// `workspaceDeployPerm` tracks whichever side the current direction targets.
let deployPerms = $state<Record<string, DeployPermission>>({})
const deployPermFetched = new Set<string>()
$effect(() => {
@@ -985,7 +992,17 @@
void checkDeployPermission(ws).then((p) => (deployPerms = { ...deployPerms, [ws]: p }))
}
})
let deployPerm = $derived(deployPerms[deployTargetWorkspace] ?? { ok: true })
let workspaceDeployPerm = $derived(deployPerms[deployTargetWorkspace] ?? { ok: true })
// A direct-deployment lock never reaches schedules or triggers server-side, so it must not
// disable a selection made only of those. One refused kind still blocks the whole action.
let deployPerm = $derived(
deployPermissionForKinds(
workspaceDeployPerm,
(comparison?.diffs ?? [])
.filter((d) => selectedItems.includes(getItemKey(d)))
.map((d) => d.kind)
)
)
// Fetch summaries and on_behalf_of_email when comparison data loads
$effect(() => {
@@ -1072,15 +1072,15 @@
disabled={model.deploying ||
!!staged[d.key] ||
!view.canWrite ||
!model.deployPermission.ok}
!model.deployPermissionForKind(d.deployKind).ok}
startIcon={status?.status === 'loading'
? { icon: Loader2, classes: 'animate-spin' }
: { icon: Save }}
title={!view.canWrite
? "You don't have write permission on this path"
: model.deployPermission.ok
: model.deployPermissionForKind(d.deployKind).ok
? undefined
: model.deployPermission.reason}
: model.deployPermissionForKind(d.deployKind).reason}
onclick={() => deployStaged(d)}
>
{action.label}
@@ -2,10 +2,12 @@ import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import {
checkDeployPermission,
checkItemExists,
deployPermissionForKind,
getItemValue,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
import type { Kind } from '$lib/utils_deployable'
import {
deployDraft,
discardDraft,
@@ -208,9 +210,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
})
// ── Deploy permission ────────────────────────────────────────────────────
// Preflight the shared checkDeployPermission (operator / RestrictDeployToDeployers)
// for the session workspace so the button disables with a reason instead of
// failing on click. `ok` defaults true while resolving (fail-open).
// Preflight the shared checkDeployPermission for the session workspace so the
// button disables with a reason instead of failing on click. `ok` defaults
// true while resolving (fail-open).
let deployPerm = $state<DeployPermission>({ ok: true })
let deployPermFetchedFor = ''
$effect(() => {
@@ -266,10 +268,11 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
// Snapshot before the await: the user may switch sessions while the
// deploy runs, and the event belongs to the initiating session.
const initiatingSessionId = sessionState.currentSessionId
// Don't attempt a deploy we know the user can't make (no write permission
// on the path, or blocked by the operator / deployer rule) — the UI
// disables it too; this is the guard behind that.
if (!discard && (!item.canWrite || !deployPerm.ok)) return false
// Don't attempt a deploy we know the user can't make (no write permission on
// the path, or refused by the preflight for this kind) — the UI disables it
// too; this is the guard behind that.
if (!discard && (!item.canWrite || !deployPermissionForKind(deployPerm, item.deployKind).ok))
return false
setStatus(item.key, { status: 'loading' })
deploying = true
try {
@@ -349,9 +352,13 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
staleOf(key: string): boolean {
return staleKeys.has(key)
},
/** Whether the user may deploy into the session workspace. */
get deployPermission(): DeployPermission {
return deployPerm
/**
* Whether the user may deploy into the session workspace. Per-kind, because a
* direct-deployment lock never reaches schedules or triggers server-side a row of
* that kind stays deployable while a script row does not.
*/
deployPermissionForKind(kind: Kind): DeployPermission {
return deployPermissionForKind(deployPerm, kind)
},
deployRow,
discardRow
+169 -3
View File
@@ -1,11 +1,25 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import type { ProtectionRuleset, ProtectionRuleKind, User } from './gen'
import type { DeployPermission } from './utils_workspace_deploy'
import {
checkDeployPermission,
deployPermissionForKind,
deployPermissionForKinds,
kindGatedByDeployRules,
checkPathWritePermission,
diffActionableInDirection,
diffCreatesInTarget,
diffRemovesInTarget
} from './utils_workspace_deploy'
// Only the rules fetch is stubbed: the bypass logic it feeds is the code under test here,
// so it has to stay real.
let rulesets: ProtectionRuleset[] = []
vi.mock('$lib/workspaceProtectionRules.svelte', async (importOriginal) => ({
...((await importOriginal()) as object),
fetchProtectionRulesForWorkspace: async () => rulesets
}))
/** The row shape the fork comparison returns for an item the parent has and the
* fork does not: one write on the fork side, whatever that write was. */
const parentOnly = { ahead: 1, behind: 0, exists_in_source: true, exists_in_fork: false }
@@ -71,13 +85,13 @@ describe('deploy direction of a one-sided diff row', () => {
})
describe('per-item write permission in the deploy target', () => {
const member = { is_admin: false, username: 'alice', folders: ['shared'] }
const member = { is_admin: false, is_super_admin: false, username: 'alice', folders: ['shared'] }
const never = async () => {
throw new Error('folder probe should not run')
}
it('lets a workspace admin write anywhere', async () => {
const admin = { is_admin: true, username: 'root', folders: [] }
const admin = { is_admin: true, is_super_admin: false, username: 'root', folders: [] }
expect(await checkPathWritePermission('dev', 'u/someone/x', admin, never)).toEqual({ ok: true })
expect(await checkPathWritePermission('dev', 'f/locked/x', admin, never)).toEqual({ ok: true })
})
@@ -89,6 +103,14 @@ describe('per-item write permission in the deploy target', () => {
expect(refused.reason).toContain('u/bob')
})
// The server's `is_owner` reads the merged `is_admin || super_admin`, so a superadmin who is a
// plain member owns every path — refusing them here would block a write the server accepts.
it('lets a superadmin who is a plain member write anywhere', async () => {
const su = { is_admin: false, is_super_admin: true, username: 'root', folders: [] }
expect(await checkPathWritePermission('dev', 'f/locked/x', su, never)).toEqual({ ok: true })
expect(await checkPathWritePermission('dev', 'u/someone/x', su, never)).toEqual({ ok: true })
})
it('allows a folder in the write set without probing for it', async () => {
expect(await checkPathWritePermission('dev', 'f/shared/x', member, never)).toEqual({ ok: true })
})
@@ -116,3 +138,147 @@ describe('per-item write permission in the deploy target', () => {
})
})
})
describe('workspace-level deploy permission', () => {
const ruleset = (name: string, rules: ProtectionRuleKind[]): ProtectionRuleset => ({
name,
rules,
bypass_users: [],
bypass_groups: []
})
const member: User = {
email: 'alice@windmill.dev',
username: 'alice',
is_admin: false,
is_super_admin: false,
operator: false,
disabled: false,
created_at: '2024-01-01T00:00:00Z',
groups: [],
folders: [],
folders_read: [],
folders_owners: []
}
const permission = (me: User, rules: ProtectionRuleset[]) => {
rulesets = rules
return checkDeployPermission('prod', me)
}
it('refuses a member when direct deployment is disabled', async () => {
const res = await permission(member, [ruleset('lock', ['DisableDirectDeployment'])])
expect(res.ok).toBe(false)
expect(res.reason).toContain('prod')
})
// The reserved dev-workspace lock sets DisableWorkspaceForking alongside, so the advice would
// otherwise send the user at a second blocked action.
it('drops the fork advice when forking is blocked too', async () => {
const res = await permission(member, [
ruleset('lock', ['DisableDirectDeployment', 'DisableWorkspaceForking'])
])
expect(res.reason).not.toContain('fork')
expect(res.reason).toContain('locally')
})
// wm_deployers is an implicit pass on RestrictDeployToDeployers only. Letting it
// short-circuit the whole check — as an "is this user a deployer?" early return would —
// walks a deployer straight through a deploy-locked workspace.
it('still refuses a wm_deployers member when direct deployment is disabled', async () => {
const deployer = { ...member, groups: ['wm_deployers'] }
expect((await permission(deployer, [ruleset('lock', ['DisableDirectDeployment'])])).ok).toBe(
false
)
expect((await permission(deployer, [ruleset('gate', ['RestrictDeployToDeployers'])])).ok).toBe(
true
)
})
// whoami reports is_admin and is_super_admin separately; the server sees them merged.
it('lets a superadmin who is a plain member deploy', async () => {
const su = { ...member, is_super_admin: true }
expect((await permission(su, [ruleset('lock', ['DisableDirectDeployment'])])).ok).toBe(true)
})
it('reports the direct-deployment refusal when both rules block', async () => {
const res = await permission(member, [
ruleset('lock', ['DisableDirectDeployment', 'RestrictDeployToDeployers'])
])
expect(res.reason).toContain('Direct deployment')
})
// The server refuses an operator in the item handler regardless of their global role: a
// superadmin who is an operator in the workspace still gets 401 creating a script. So the
// operator term has to stay above the admin/superadmin short-circuit — hoisting the admin
// checks would offer a deploy the server refuses.
it('refuses an operator who is also a superadmin', async () => {
const res = await permission({ ...member, operator: true, is_super_admin: true }, [])
expect(res.ok).toBe(false)
expect(res.reason).toContain('operator')
})
})
describe('scoping a refusal to the kinds the server gates', () => {
const locked: DeployPermission = {
ok: false,
reason: 'Direct deployment to prod is disabled',
refusedBy: 'DisableDirectDeployment'
}
const deployersOnly: DeployPermission = {
ok: false,
reason: 'Only workspace admins and members of wm_deployers can deploy to prod',
refusedBy: 'RestrictDeployToDeployers'
}
// The kinds whose handlers call check_deploy_rules, against those that reach no gate.
it('gates the item kinds the server gates, and no others', () => {
for (const k of [
'script',
'flow',
'app',
'raw_app',
'resource',
'resource_type',
'variable',
'folder'
] as const) {
expect(kindGatedByDeployRules(k)).toBe(true)
}
for (const k of ['schedule', 'http_trigger', 'email_trigger', 'data_pipeline'] as const) {
expect(kindGatedByDeployRules(k)).toBe(false)
}
})
// Draft rows speak UserDraftItemKind; the gated names coincide, the ungated ones don't.
it('reads draft kinds with the same lookup', () => {
expect(kindGatedByDeployRules('variable')).toBe(true)
expect(kindGatedByDeployRules('trigger_schedule')).toBe(false)
expect(kindGatedByDeployRules('trigger_http')).toBe(false)
})
it('keeps a direct-deployment refusal off the kinds the server never gates', () => {
expect(deployPermissionForKind(locked, 'script').ok).toBe(false)
expect(deployPermissionForKind(locked, 'schedule').ok).toBe(true)
expect(deployPermissionForKind(locked, 'trigger_schedule').ok).toBe(true)
})
// The deployers-only term over-reaches the same way, but it does so on main too. Narrowing it
// would loosen the UI beyond mirroring the new rule, so it stays workspace-wide.
it('leaves the deployers-only refusal applying to every kind', () => {
expect(deployPermissionForKind(deployersOnly, 'script').ok).toBe(false)
expect(deployPermissionForKind(deployersOnly, 'schedule').ok).toBe(false)
})
// `[].some()` is false, so an unguarded fold reports the empty selection as deployable and
// drops the only message saying why the action is unavailable.
it('keeps the refusal when nothing is selected', () => {
expect(deployPermissionForKinds(locked, []).ok).toBe(false)
expect(deployPermissionForKinds(deployersOnly, []).ok).toBe(false)
})
it('blocks a mixed selection but frees an all-ungated one', () => {
expect(deployPermissionForKinds(locked, ['schedule', 'script']).ok).toBe(false)
expect(deployPermissionForKinds(locked, ['schedule', 'http_trigger']).ok).toBe(true)
expect(deployPermissionForKinds(deployersOnly, ['schedule']).ok).toBe(false)
})
})
+133 -20
View File
@@ -21,6 +21,7 @@ import {
WorkspaceService,
type User
} from '$lib/gen'
import type { UserDraftItemKind } from '$lib/gen'
import {
fetchProtectionRulesForWorkspace,
canUserBypassRuleKindInRulesets
@@ -703,16 +704,94 @@ export async function createFolderIfAbsent(
}
}
export type DeployPermission = { ok: boolean; reason?: string }
/** Which term refused, so a caller can scope a refusal the server applies to only some kinds. */
export type DeployRefusal = 'operator' | 'DisableDirectDeployment' | 'RestrictDeployToDeployers'
export type DeployPermission = { ok: boolean; reason?: string; refusedBy?: DeployRefusal }
// The server reaches `check_deploy_rules` from the item handlers, and only these kinds call it
// (windmill-api-{scripts,flows,groups}, windmill-api/src/apps.rs, windmill-store/src/{resources,
// variables}.rs). Schedules and triggers hit no gate at all, so a protection rule must not
// disable them here. Exhaustive by construction: a new `Kind` fails to compile without a verdict.
const KIND_GATED_BY_DEPLOY_RULES: Record<Kind, boolean> = {
script: true,
flow: true,
app: true,
raw_app: true,
resource: true,
resource_type: true,
variable: true,
folder: true,
schedule: false,
http_trigger: false,
websocket_trigger: false,
kafka_trigger: false,
nats_trigger: false,
postgres_trigger: false,
mqtt_trigger: false,
amqp_trigger: false,
sqs_trigger: false,
gcp_trigger: false,
azure_trigger: false,
email_trigger: false,
datatable_migration: false,
trigger: false,
data_pipeline: false
}
// Every gated kind is spelled identically in `Kind` and `UserDraftItemKind`, so one lookup serves
// both taxonomies and the drafts surface needs no bridge. The kinds whose spellings diverge
// (`trigger_http` vs `http_trigger`) are exactly the ungated ones — so if a trigger kind ever
// becomes gated server-side, it needs its draft spelling added here too.
const GATED_KIND_NAMES = new Set(
Object.entries(KIND_GATED_BY_DEPLOY_RULES)
.filter(([, gated]) => gated)
.map(([kind]) => kind)
)
export function kindGatedByDeployRules(kind: Kind | UserDraftItemKind): boolean {
return GATED_KIND_NAMES.has(kind)
}
/**
* Whether the current user may deploy into `workspace`. Mirrors the server-side
* deploy authorization (`check_user_against_rule` in windmill-common) so the UI
* can disable the action with a reason instead of letting the click 403:
* - operators can never deploy;
* - when the `RestrictDeployToDeployers` protection rule is active, only
* admins, `wm_deployers` members (implicitly), and per-ruleset bypass
* users/groups may deploy.
* Narrow a workspace-level refusal to one item kind. Only the direct-deployment term is scoped:
* the deployers-only term over-reaches the same way, but it does so on `main` too, and loosening
* it here would change behaviour beyond mirroring the server.
*/
export function deployPermissionForKind(
perm: DeployPermission,
kind: Kind | UserDraftItemKind
): DeployPermission {
if (perm.ok) return perm
if (perm.refusedBy === 'DisableDirectDeployment' && !kindGatedByDeployRules(kind)) {
return { ok: true }
}
return perm
}
/** The refusal a bulk action carries: it applies as soon as one selected kind is still refused. */
export function deployPermissionForKinds(
perm: DeployPermission,
kinds: (Kind | UserDraftItemKind)[]
): DeployPermission {
if (perm.ok) return perm
// An empty selection keeps the workspace-level refusal, which is then the only thing left
// to say why the action is unavailable.
if (kinds.length === 0) return perm
return kinds.some((k) => !deployPermissionForKind(perm, k).ok) ? perm : { ok: true }
}
/**
* Whether the current user may deploy into `workspace`. Mirrors `check_deploy_rules` in
* windmill-common so the UI can disable the action with a reason instead of letting the
* click 403: `DisableDirectDeployment` is evaluated before `RestrictDeployToDeployers`, so
* the same message wins here as on the server when both block; admins and superadmins bypass
* both rules, while `wm_deployers` members bypass only the latter.
*
* The operator refusal is not part of that mirror. The server refuses operators in the item
* handlers instead, and for fewer kinds, so refusing them for everything here is deliberately
* stricter than the server rather than a faithful copy of it.
*
* Fails open on any error the server still enforces on the actual deploy.
* Shared by the session dock and the compare page so both gate identically.
*/
@@ -724,19 +803,50 @@ export async function checkDeployPermission(
try {
const me = whoami ?? (await UserService.whoami({ workspace }))
if (me.operator) {
return { ok: false, reason: "You're an operator in this workspace — operators can't deploy" }
return {
ok: false,
reason: "You're an operator in this workspace — operators can't deploy",
refusedBy: 'operator'
}
}
// Admins and wm_deployers members always satisfy RestrictDeployToDeployers
// (the backend allows wm_deployers implicitly, so check it before the
// per-ruleset bypass_users/bypass_groups fallback).
const isDeployer = me.is_admin || (me.groups ?? []).includes('wm_deployers')
if (!isDeployer) {
const userInfo = {
is_admin: !!me.is_admin,
is_super_admin: !!me.is_super_admin,
username: me.username,
groups: me.groups ?? []
}
// A superadmin who is only a plain member of the workspace still bypasses every rule,
// so dropping either term here refuses a deploy the server accepts.
if (!userInfo.is_admin && !userInfo.is_super_admin) {
const rulesets = await fetchProtectionRulesForWorkspace(workspace)
const userInfo = { is_admin: !!me.is_admin, username: me.username, groups: me.groups ?? [] }
if (!canUserBypassRuleKindInRulesets(rulesets, 'RestrictDeployToDeployers', userInfo)) {
if (!canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', userInfo)) {
// The reserved dev-workspace lock carries DisableWorkspaceForking alongside this rule,
// so suggesting a fork unconditionally would point at a second blocked action.
const canFork = canUserBypassRuleKindInRulesets(
rulesets,
'DisableWorkspaceForking',
userInfo
)
const advice = canFork
? 'fork the workspace or open a pull request'
: 'make your changes locally and open a pull request'
return {
ok: false,
reason: 'Only workspace admins and members of wm_deployers can deploy here'
reason: `Direct deployment to ${workspace} is disabled — ${advice}`,
refusedBy: 'DisableDirectDeployment'
}
}
// `wm_deployers` membership is an implicit pass on this rule only, so it cannot
// short-circuit the fetch above the way admin does.
const isDeployer = userInfo.groups.includes('wm_deployers')
if (
!isDeployer &&
!canUserBypassRuleKindInRulesets(rulesets, 'RestrictDeployToDeployers', userInfo)
) {
return {
ok: false,
reason: `Only workspace admins and members of wm_deployers can deploy to ${workspace}`,
refusedBy: 'RestrictDeployToDeployers'
}
}
}
@@ -758,11 +868,13 @@ export async function checkDeployPermission(
export async function checkPathWritePermission(
workspace: string,
path: string,
me: Pick<User, 'is_admin' | 'username' | 'folders'>,
me: Pick<User, 'is_admin' | 'is_super_admin' | 'username' | 'folders'>,
folderExists: (folderPath: string) => Promise<boolean> = (folderPath) =>
checkItemExists('folder', folderPath, workspace)
): Promise<DeployPermission> {
if (me.is_admin) return { ok: true }
// The server's `is_owner` reads `ApiAuthed.is_admin`, the merged `is_admin || super_admin`,
// so a superadmin owns every path here whether or not they own the folder.
if (me.is_admin || me.is_super_admin) return { ok: true }
const owner = path.match(/^u\/([^/]+)\//)?.[1]
if (owner) {
return owner === me.username
@@ -813,7 +925,8 @@ export async function checkItemDeployAccess(
permission: workspaceLevel.ok
? await checkPathWritePermission(workspace, path, me)
: workspaceLevel,
canPreserveOnBehalfOf: me.is_admin || (me.groups ?? []).includes('wm_deployers'),
canPreserveOnBehalfOf:
me.is_admin || me.is_super_admin || (me.groups ?? []).includes('wm_deployers'),
me: { email: me.email, permissionedAs: `u/${me.username}` }
}
}
@@ -4,7 +4,7 @@ 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'>
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
@@ -102,11 +102,12 @@ export async function fetchProtectionRulesForWorkspace(
* 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)
* @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 {
// Admin always bypasses
if (userInfo.is_admin) {
// 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
}
@@ -108,7 +108,7 @@
// Only non-draft_only rows carry a base pointer in production.
return staleDrafts && !items.find((it) => it.key === key)?.draftOnly
},
get deployPermission() {
deployPermissionForKind() {
return permOk
? { ok: true as const }
: { ok: false as const, reason: 'Deploy disabled by the playground toggle' }