diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 88107f8bba..a05511ea42 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -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([]) 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({ 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({ 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. diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 875eda25ce..375e73480e 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -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>({}) const deployPermFetched = new Set() $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(() => { diff --git a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte index d66a92dcdb..c35ee5a2fc 100644 --- a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte @@ -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} diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts index 8332782cdf..981b4cc752 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts @@ -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({ 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 diff --git a/frontend/src/lib/utils_workspace_deploy.test.ts b/frontend/src/lib/utils_workspace_deploy.test.ts index 08a877d012..f220fc93b9 100644 --- a/frontend/src/lib/utils_workspace_deploy.test.ts +++ b/frontend/src/lib/utils_workspace_deploy.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index c330a2210b..382bf94aea 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -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 = { + 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, + me: Pick, folderExists: (folderPath: string) => Promise = (folderPath) => checkItemExists('folder', folderPath, workspace) ): Promise { - 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}` } } } diff --git a/frontend/src/lib/workspaceProtectionRules.svelte.ts b/frontend/src/lib/workspaceProtectionRules.svelte.ts index 40eeee719b..01d122794d 100644 --- a/frontend/src/lib/workspaceProtectionRules.svelte.ts +++ b/frontend/src/lib/workspaceProtectionRules.svelte.ts @@ -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 +export type RuleBypassUser = Pick // 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 } diff --git a/frontend/src/routes/kitchen_sink/deploy_animation/+page.svelte b/frontend/src/routes/kitchen_sink/deploy_animation/+page.svelte index 6cd7e7197f..327f64f962 100644 --- a/frontend/src/routes/kitchen_sink/deploy_animation/+page.svelte +++ b/frontend/src/routes/kitchen_sink/deploy_animation/+page.svelte @@ -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' }