diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 6fc7ca3d88..a042c28f44 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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' diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 3c512036c3..2f1e00c09b 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -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() - 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) { diff --git a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts index c46b81cbd0..431620c761 100644 --- a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts @@ -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) => - 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) } diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index 755534e8a9..688901a180 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -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 { 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 } diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index bbe9035dc6..b7a8014936 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -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 | 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 | 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( diff --git a/frontend/src/lib/operatorWriteRights.ts b/frontend/src/lib/operatorWriteRights.ts index ca0fcc7dcc..3980a222ab 100644 --- a/frontend/src/lib/operatorWriteRights.ts +++ b/frontend/src/lib/operatorWriteRights.ts @@ -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 | undefined) => canEditItem(kind, path, extraPerms, user.current, settings()), + roleCanDraft: (kind: string) => roleCanDraft(kind, user.current, settings()), + canDraftItem: (kind: string, path: string, extraPerms: Record | 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()