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:
Guilhem Lemouel
2026-10-09 07:01:32 -07:00
co-authored by Claude Opus 5.5
parent d286bcafb3
commit da76b1a537
6 changed files with 50 additions and 17 deletions
@@ -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)
}
+8 -1
View File
@@ -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 }
+25
View File
@@ -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(
+4 -1
View File
@@ -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()