mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +00:00
fix: let admins who are also operators open session editors, as drafts.rs does
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d286bcafb3
commit
da76b1a537
@@ -25,7 +25,7 @@ import {
|
||||
import { createTwoFilesPatch } from 'diff'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import { canEditItem, roleCanAuthor } from '$lib/editRights'
|
||||
import { canDraftItem, roleCanDraft } from '$lib/editRights'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
AppWithLastVersion,
|
||||
@@ -4938,10 +4938,10 @@ async function canEditItemPath(
|
||||
if (role.kind !== 'resolved' || !role.user) return 'unverified'
|
||||
const user = role.user
|
||||
const settings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings
|
||||
if (!roleCanAuthor(kind, user, settings)) return 'denied'
|
||||
if (!roleCanDraft(kind, user, settings)) return 'denied'
|
||||
// Folder and ownership rules answer most calls without a request. An item's own
|
||||
// `extra_perms` can only widen them, so the item is fetched only to overturn a denial.
|
||||
if (canEditItem(kind, path, {}, user, settings)) return 'allowed'
|
||||
if (canDraftItem(kind, path, {}, user, settings)) return 'allowed'
|
||||
try {
|
||||
const extraPerms =
|
||||
kind === 'script'
|
||||
@@ -4949,7 +4949,7 @@ async function canEditItemPath(
|
||||
: kind === 'flow'
|
||||
? (await FlowService.getFlowByPath({ workspace, path })).extra_perms
|
||||
: (await AppService.getAppByPath({ workspace, path })).extra_perms
|
||||
return canEditItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied'
|
||||
return canDraftItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied'
|
||||
} catch (e) {
|
||||
// Nothing deployed means no sharing to widen the folder rules with.
|
||||
return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { get } from 'svelte/store'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import type { OperatorSettings } from '$lib/gen'
|
||||
import { getWorkspaceRole } from '$lib/user'
|
||||
import { roleCanAuthor } from '$lib/editRights'
|
||||
import { roleCanAuthor, roleCanDraft } from '$lib/editRights'
|
||||
import { checkDeployRules } from '$lib/utils_workspace_deploy'
|
||||
import type { SessionAccess, SessionCapability } from '../sessionCapabilities'
|
||||
|
||||
@@ -43,17 +43,15 @@ export function capabilitiesForRole(role: {
|
||||
operatorSettings?: OperatorRights | null
|
||||
}): SessionAccess {
|
||||
const capabilities = new Set<SessionCapability>()
|
||||
const user = { operator: role.operator }
|
||||
const user = { operator: role.operator, is_admin: role.isAdmin, is_super_admin: false }
|
||||
const authorsCode = roleCanAuthor('script', user, role.operatorSettings)
|
||||
const authorsFlows = roleCanAuthor('flow', user, role.operatorSettings)
|
||||
// Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path`
|
||||
// returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs
|
||||
// `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no
|
||||
// admin escape — and on the session path that flag is never cleared for an admin.
|
||||
if (role.isAdmin || authorsCode) {
|
||||
// Drafts take the admin precedence `roleCanDraft` encodes; previews and the item handlers
|
||||
// do not — and on the session path the operator flag is never cleared for an admin.
|
||||
if (roleCanDraft('script', user, role.operatorSettings)) {
|
||||
capabilities.add('write_draft')
|
||||
}
|
||||
if (role.isAdmin || authorsFlows) {
|
||||
if (roleCanDraft('flow', user, role.operatorSettings)) {
|
||||
capabilities.add('write_flow_draft')
|
||||
}
|
||||
if (authorsCode) {
|
||||
|
||||
@@ -20,11 +20,11 @@ export function useSessionPermissions(workspace: () => string | undefined) {
|
||||
const entry = $derived(workspaces.current.find((w) => w.id === workspace()))
|
||||
|
||||
return {
|
||||
/** Whether this user's role can save what the `kind` editor writes. */
|
||||
canOpenEditor: (kind: string) => rights.roleCanAuthor(kind),
|
||||
/** Whether this user can save the drafts the `kind` editor writes. */
|
||||
canOpenEditor: (kind: string) => rights.roleCanDraft(kind),
|
||||
/** The same, and this item's own permissions let them write it. */
|
||||
canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'path' | 'extraPerms'>) =>
|
||||
rights.canEditItem(item.kind, item.path, item.extraPerms),
|
||||
rights.canDraftItem(item.kind, item.path, item.extraPerms),
|
||||
/** Whether the page loads for this user rather than refusing them. */
|
||||
pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { canEditItem, roleCanAuthor } from './editRights'
|
||||
import { canEditItem, roleCanAuthor, roleCanDraft } from './editRights'
|
||||
|
||||
function user(overrides: Partial<UserExt>): UserExt {
|
||||
return {
|
||||
@@ -28,6 +28,13 @@ describe('edit rights', () => {
|
||||
expect(roleCanAuthor('flow', user({}), undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// drafts.rs admits an admin before its operator branch; the item handlers do not.
|
||||
it('lets an admin who is also an operator save drafts, but not author code', () => {
|
||||
const adminOperator = user({ is_admin: true })
|
||||
expect(roleCanDraft('script', adminOperator, undefined)).toBe(true)
|
||||
expect(roleCanAuthor('script', adminOperator, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// The role alone is not enough: a builder still cannot edit a flow in a folder they only read.
|
||||
it('requires write access to the item on top of the role', () => {
|
||||
const builder = { builder_flows: true }
|
||||
|
||||
@@ -62,6 +62,31 @@ export function roleCanAuthor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user may save drafts of `kind` — what every session editor writes. Drafts are the
|
||||
* one place an admin outranks the operator flag: drafts.rs `require_can_write_path` returns Ok
|
||||
* on `is_admin` before its operator branch, while the item handlers refuse an operator with no
|
||||
* admin escape, so `roleCanAuthor` stays the rule for creating, deploying and deleting.
|
||||
*/
|
||||
export function roleCanDraft(
|
||||
kind: EditKind,
|
||||
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined,
|
||||
operatorSettings: OperatorRights
|
||||
): boolean {
|
||||
return !!user?.is_admin || !!user?.is_super_admin || roleCanAuthor(kind, user, operatorSettings)
|
||||
}
|
||||
|
||||
/** `roleCanDraft` for the item at `path`, with its own permissions as in `canEditItem`. */
|
||||
export function canDraftItem(
|
||||
kind: EditKind,
|
||||
path: string,
|
||||
extraPerms: Record<string, boolean> | undefined,
|
||||
user: UserExt | undefined,
|
||||
operatorSettings: OperatorRights
|
||||
): boolean {
|
||||
return roleCanDraft(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user)
|
||||
}
|
||||
|
||||
/** Whether `user` may edit the item at `path`: their role authors `kind`, and the item's own
|
||||
* permissions (`canWrite`: ownership, folder write, `extra_perms`) let them write it. */
|
||||
export function canEditItem(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { derived, fromStore, type Readable } from 'svelte/store'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { canEditItem, roleCanAuthor } from '$lib/editRights'
|
||||
import { canDraftItem, canEditItem, roleCanAuthor, roleCanDraft } from '$lib/editRights'
|
||||
|
||||
/**
|
||||
* `roleCanAuthor` and `canEditItem` answered for the user acting in the operating workspace,
|
||||
@@ -20,6 +20,9 @@ export function useEditRights(workspace?: () => string | undefined) {
|
||||
roleCanAuthor: (kind: string) => roleCanAuthor(kind, user.current, settings()),
|
||||
canEditItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) =>
|
||||
canEditItem(kind, path, extraPerms, user.current, settings()),
|
||||
roleCanDraft: (kind: string) => roleCanDraft(kind, user.current, settings()),
|
||||
canDraftItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) =>
|
||||
canDraftItem(kind, path, extraPerms, user.current, settings()),
|
||||
/** This hook looks the user up on its own, so an editor that retries a failed lookup on
|
||||
* its acting user (`forgetFailures`) must retry this one too, or it stays read-only. */
|
||||
forgetFailures: () => user.forgetFailures()
|
||||
|
||||
Reference in New Issue
Block a user