diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 1369e33fb2..8cf1693bc7 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -1,7 +1,7 @@ (typedEmail = '')}> @@ -157,6 +171,18 @@ {#snippet content()}
Add a new user +
+ +
{#if isServiceAccount} Username @@ -217,38 +243,12 @@ {/if} {/if} - Role - - {#snippet children({ item })} - - - - - {/snippet} - - - {#if isServiceAccount} + {#if !isServiceAccount} + Role +
+ (picked = v)} /> +
+ {:else} Service account role {#snippet children({ item })} diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index a05511ea42..976f82c382 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -34,6 +34,7 @@ } from '$lib/workspaceDrafts.svelte' import type { Kind as LayoutKind } from '$lib/utils_deployable' import { userStore, userWorkspaces } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' interface Props { @@ -329,7 +330,12 @@ workspaceDeployPerm, visibleItems .filter((i) => selectedItems.includes(i.key) && isDeployable(i)) - .map((i) => i.draftKind) + .map((i) => i.draftKind), + capabilitiesIn( + // Superadmin is global; a workspace's admin flag is not. + { is_super_admin: $userStore?.is_super_admin }, + $userWorkspaces.find((w) => w.id === currentWorkspaceId) + ) ) ) // Select all on the first non-empty load (acting on everything is the common diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 5a22ed012d..1c718287fc 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -37,6 +37,7 @@ import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' import { userWorkspaces, workspaceStore } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import type { Kind } from '$lib/utils_deployable' import { @@ -1090,7 +1091,12 @@ workspaceDeployPerm, (comparison?.diffs ?? []) .filter((d) => selectedItems.includes(getItemKey(d))) - .map((d) => d.kind) + .map((d) => d.kind), + capabilitiesIn( + // Superadmin is global; a workspace's admin flag is not. + { is_super_admin: $userStore?.is_super_admin }, + $userWorkspaces.find((w) => w.id == deployTargetWorkspace) + ) ) ) diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 22f7c4e55a..4d21e81b29 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -18,7 +18,8 @@ import { goto } from '$lib/navigation' import { OtherUserDraftLoad, editRouteFor } from '$lib/components/otherUserDraftLoad.svelte' import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff' - import { userStore } from '$lib/stores' + import { roleCapabilities, userStore } from '$lib/stores' + import { roleCanAuthor } from '$lib/editRights' type DraftUser = { username?: string | null } @@ -116,6 +117,10 @@ // Inline actions need full context to fetch and fork drafts. const actionsEnabled = $derived(!!workspace && !!itemKind && !!path && draft_users.length > 0) + // The editors these open are the full ones, which a role without code cannot use. + const canOpenEditor = $derived( + !!itemKind && $roleCapabilities.code && roleCanAuthor(itemKind, $roleCapabilities) + ) const kindLabel = $derived( itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script' @@ -303,7 +308,7 @@ {#if actionsEnabled && isSelf} - {#if !$userStore?.operator && itemKind && path} + {#if canOpenEditor && itemKind && path}
diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 11779afb11..1736520eed 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -3,7 +3,13 @@ import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { Badge } from './common' import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte' - import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores' + import { + roleCapabilities, + workspaceStore, + userWorkspaces, + userStore, + type UserExt + } from '$lib/stores' import type { WorkspaceComparison } from '$lib/gen' import { fetchWorkspaceComparison } from '$lib/workspaceComparison' import { Button } from './common' @@ -27,18 +33,18 @@ // prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped. let isFork = $derived(parentWorkspaceId != null) let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData)) - // Operators run scripts and flows, they never deploy a fork, so the banner and - // its CTA are noise for them. Gates the fetches too, not just the markup: the - // fork/parent comparison is an expensive tally no operator can act on. + // A role without `code` never deploys a fork, so the banner and its CTA are noise + // for it. Gates the fetches too, not just the markup: the fork/parent comparison + // is an expensive tally such a role cannot act on. // - // Only a role fetched for the workspace we are on answers this. `$workspaceStore` + // Only a user fetched for the workspace we are on answers this. `$workspaceStore` // flips synchronously on a switch while `$userStore` still holds the workspace we // left, so trusting it unqualified would flash the banner at (and start the tally - // for) an operator entering a fork from a workspace where they are not one. - function isConfirmedNonOperator(user: UserExt | undefined, ws: string | undefined): boolean { - return !!user && !!ws && user.workspace_id === ws && !user.operator + // for) a restricted member entering a fork from a workspace where they are not one. + function isUserFor(user: UserExt | undefined, ws: string | undefined): boolean { + return !!user && !!ws && user.workspace_id === ws } - let isNotOperator = $derived(isConfirmedNonOperator($userStore, $workspaceStore)) + let isNotOperator = $derived(isUserFor($userStore, $workspaceStore) && $roleCapabilities.code) let showBanner = $derived(isFork && isNotOperator) // Drafts in this fork. When the fork is otherwise in sync with its parent, a diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index a3a2d19fd6..ad2ee14b99 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -22,6 +22,7 @@ import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' import RetentionPeriodOverrides from './instanceSettings/RetentionPeriodOverrides.svelte' import AgentWorkloadIdentityIssuers from './instanceSettings/AgentWorkloadIdentityIssuers.svelte' + import WorkspaceRolesSetting from './instanceSettings/WorkspaceRolesSetting.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' import ExternalInstancePgSettings from './instanceSettings/ExternalInstancePgSettings.svelte' @@ -342,6 +343,8 @@ getInstanceConfig() has populated the persisted overrides (which would let a save drop them). --> + {:else if setting.fieldType == 'workspace_roles'} + {:else} + {:else if category == 'Workspace roles'} + {:else if category == 'Service logs'} import { Button } from './common' import Popover from './meltComponents/Popover.svelte' - - import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import RolePicker from './settings/RolePicker.svelte' + import type { EffectiveRole } from '$lib/gen' + import { validRoleOr } from '$lib/workspaceRoles' import { MailPlus } from 'lucide-svelte' interface Props { - inviteUser: (email: string, selected: 'operator' | 'developer' | 'admin') => Promise + /** `selected` is a role name, or `admin`. */ + inviteUser: (email: string, selected: string) => Promise + /** The workspace's roles; undefined while loading. */ + roles: EffectiveRole[] | undefined } - const { inviteUser }: Props = $props() + const { inviteUser, roles }: Props = $props() let email: string = $state('') @@ -23,7 +26,8 @@ } } - let selected: 'operator' | 'developer' | 'admin' = $state('developer') + let picked: string | undefined = $state(undefined) + const selected = $derived(validRoleOr(roles, picked)) @@ -46,30 +50,7 @@ bind:value={email} class="mr-4" /> - - {#snippet children({ item })} - - - - - - {/snippet} - + (picked = v)} /> - {/snippet} - -
- - -
- -
- - -
- -
- - - - Section - Description - - (allDisabled ? 'false' : allEnabled ? 'true' : ''), - (v) => { - Object.keys(operatorWorkspaceSettings).forEach((key) => { - if (v === 'true') operatorWorkspaceSettings[key] = true - if (v === 'false') operatorWorkspaceSettings[key] = false - }) - } - } - > - {#snippet children({ item })} - - - {/snippet} - - - - - - {#each Object.entries(descriptions) as [key, { title, description }]} - - {title} - {description} - - (operatorWorkspaceSettings[key] = detail === 'on')} - > - {#snippet children({ item })} - - - {/snippet} - - - - {/each} - - -
- - - (confirmBuilderOpen = false)} - onConfirmed={async () => { - confirmBuilderOpen = false - await saveSettings() - }} -> -
- This applies to every operator of this workspace, not to a chosen few. - - Each of them then consumes a full seat instead of half a seat, which changes what this - instance is billed. - - - They can create, edit and delete {grantedBuilderKinds} wherever their folder permissions already - let them write. Review those permissions before enabling. - -
-
diff --git a/frontend/src/lib/components/settings/WorkspaceRoleOverrides.svelte b/frontend/src/lib/components/settings/WorkspaceRoleOverrides.svelte new file mode 100644 index 0000000000..12e1b3e68e --- /dev/null +++ b/frontend/src/lib/components/settings/WorkspaceRoleOverrides.svelte @@ -0,0 +1,393 @@ + + +{#snippet roleCard(name: string, role: EffectiveRole | undefined)} + {@const { base, unknown } = baseFor(name)} + {@const caps = applyCapabilitiesPatch(base, draft.get(name)?.capabilities)} + {@const overridden = Object.hasOwn(cleanedDraft, name)} + {@const frozen = !role && !data?.custom_roles_enabled} +
+
+
+ + {draft.get(name)?.label?.trim() || role?.label || name} + + {name} +
+ {#if role} + Instance + {#if overridden} + Overridden here + {/if} + {:else if frozen} + Read-only without a license + {:else} + This workspace only + {/if} + + {summary(caps)} + + + {#if role} + + {:else} +
+ {#if errors[name] && !open.get(name)} + {errors[name]} + {/if} + {#if open.get(name)} +
+
+ Label +
+ setLabel(name, e.currentTarget.value) + }} + /> +
+
+ setPatch(name, p)} + /> +
+ {/if} +
+{/snippet} + +
+ {#snippet action()} + + {/snippet} + + {#if !data} + Loading roles... + {:else} +
+ {#each instanceRoles as role (role.name)} + {@render roleCard(role.name, role)} + {/each} + {#each workspaceOnlyNames as name (name)} + {@render roleCard(name, undefined)} + {/each} + + {#if data.custom_roles_enabled} +
+
+ e.key === 'Enter' && addRole() + }} + /> + {#if newRoleError} + {newRoleError} + {/if} +
+ +
+ + A workspace role exists only here. Its unset capabilities default to the operator rights. + One still held by a member, invite, auto-invite or instance group mapping cannot be + removed. + + {:else} + + This workspace can still override what the instance roles allow. Roles added here earlier + keep applying but are read-only; they can still be removed. + + {/if} +
+ {/if} +
+ + (confirmSeatOpen = false)} + onConfirmed={async () => { + confirmSeatOpen = false + await save() + }} +> +
+ + With this change, members holding + {newAuthorRoles.join(', ')} can write code or build flows or apps in this workspace, or + may again if the instance definition allows it. + + + Each of them then consumes a full seat instead of half a seat, which changes what this + instance is billed. They can author wherever their folder permissions already let them write. + +
+
diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 8f5db5f2d9..19ca67a5f7 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -4,7 +4,17 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' - import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte' + import WorkspaceRoleOverrides from '$lib/components/settings/WorkspaceRoleOverrides.svelte' + import RolePicker from '$lib/components/settings/RolePicker.svelte' + import { + defaultRoleName, + loadWorkspaceRoles, + OPERATOR_ROLE, + pickerValue, + roleLabel, + roleRequestFields, + validRoleOr + } from '$lib/workspaceRoles' import InviteUser from '$lib/components/InviteUser.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' @@ -12,7 +22,7 @@ import Head from '$lib/components/table/Head.svelte' import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' - import type { CancelablePromise, User, UserUsage } from '$lib/gen' + import type { CancelablePromise, User, UserUsage, WorkspaceRoles } from '$lib/gen' import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen' import { userStore, @@ -39,12 +49,28 @@ let offboardingUser: string | undefined = $state(undefined) let offboardingReassignOnly = $state(false) - let users: User[] | undefined = $state(undefined) + // `role` is the member's workspace role, which `listUsers` returns but the spec's `User` omits. + type Member = User & { role?: string } + + let users: Member[] | undefined = $state(undefined) let invites: WorkspaceInvite[] = $state([]) - let filteredUsers: User[] | undefined = $state(undefined) + let filteredUsers: Member[] | undefined = $state(undefined) let userFilter = $state('') let auto_invite_domain: string | undefined = $state() - let operatorOnly: boolean | undefined = $state(undefined) + // Unset until picked or stored; the pickers and payloads fall back to the default role. + let autoInviteRole: string | undefined = $state(undefined) + let workspaceRoles = $state(undefined) + const roles = $derived(workspaceRoles?.roles) + + async function loadRoles(): Promise { + const workspace = $workspaceStore! + try { + const r = await loadWorkspaceRoles(workspace) + if (workspace === $workspaceStore) workspaceRoles = r + } catch (e) { + sendUserToast(`Failed to load the workspace roles: ${e?.body ?? e}`, true) + } + } let autoAdd: boolean | undefined = $state(false) let addAdminsAndDevelopersToForks = $state(false) let nbDisplayed = $state(30) @@ -56,7 +82,7 @@ // Add new instance group form state let selectedNewInstanceGroup: string | undefined = $state(undefined) - let selectedNewRole: string | undefined = $state('developer') + let selectedNewRole: string | undefined = $state(undefined) // Service account creation @@ -113,13 +139,14 @@ enabled?: boolean domain?: string operator?: boolean + role?: string mode?: string instance_groups?: string[] instance_groups_roles?: Record } | undefined auto_invite_domain = autoInvite?.enabled ? (autoInvite?.domain ?? '*') : undefined - operatorOnly = autoInvite?.operator ?? false + autoInviteRole = autoInvite?.role ?? (autoInvite?.operator ? OPERATOR_ROLE : undefined) autoAdd = autoInvite?.mode === 'add' autoAddInstanceGroups = autoInvite?.instance_groups || [] autoAddInstanceGroupsRoles = autoInvite?.instance_groups_roles || {} @@ -190,11 +217,11 @@ let lastSeen: { workspace: string; signature: string; seq: number } | undefined = undefined let usersIssued = 0 - // Every field a paid seat count depends on — the developer/operator split, and the - // two categories billing excludes. - function membershipSignature(list: User[] | undefined): string { + // Every field a paid seat count depends on — the role, and the two categories billing + // excludes. + function membershipSignature(list: Member[] | undefined): string { return (list ?? []) - .map((u) => `${u.email}:${u.operator}:${u.disabled}:${u.is_service_account}`) + .map((u) => `${u.email}:${u.operator}:${u.role}:${u.disabled}:${u.is_service_account}`) .sort() .join(',') } @@ -218,29 +245,25 @@ } } + // Throws, so each caller rolls back its optimistic change. async function saveInstanceGroupSettings(): Promise { - try { - await WorkspaceService.editInstanceGroups({ - workspace: $workspaceStore ?? '', - requestBody: { - groups: autoAddInstanceGroups, - roles: autoAddInstanceGroupsRoles - } - }) - sendUserToast('Instance group settings saved') - // Refresh user list to show newly auto-added users - listUsers() - } catch (e) { - console.error('Failed to save instance group settings:', e) - sendUserToast('Failed to save settings', true) - } + await WorkspaceService.editInstanceGroups({ + workspace: $workspaceStore ?? '', + requestBody: { + groups: autoAddInstanceGroups, + roles: autoAddInstanceGroupsRoles + } + }) + sendUserToast('Instance group settings saved') + // Refresh user list to show newly auto-added users + listUsers() } async function addInstanceGroup(): Promise { - if (!selectedNewInstanceGroup || !selectedNewRole) return + if (!selectedNewInstanceGroup) return const groupToAdd = selectedNewInstanceGroup - const roleToAdd = selectedNewRole + const roleToAdd = validRoleOr(roles, selectedNewRole) try { autoAddInstanceGroups = [...autoAddInstanceGroups, groupToAdd] @@ -248,14 +271,14 @@ // Reset form selectedNewInstanceGroup = undefined - selectedNewRole = 'developer' + selectedNewRole = undefined await saveInstanceGroupSettings() } catch (e) { // Rollback on error autoAddInstanceGroups = autoAddInstanceGroups.filter((g) => g !== groupToAdd) delete autoAddInstanceGroupsRoles[groupToAdd] - sendUserToast('Failed to add instance group', true) + sendUserToast(`Failed to add instance group: ${e?.body ?? e}`, true) } } @@ -273,7 +296,7 @@ if (previousRole) { autoAddInstanceGroupsRoles[groupName] = previousRole } - sendUserToast('Failed to remove instance group', true) + sendUserToast(`Failed to remove instance group: ${e?.body ?? e}`, true) } } @@ -286,7 +309,7 @@ } catch (e) { // Rollback on error autoAddInstanceGroupsRoles[groupName] = previousRole - sendUserToast('Failed to update role', true) + sendUserToast(`Failed to update role: ${e?.body ?? e}`, true) } } @@ -315,6 +338,8 @@ listInvites() loadSettings() loadInstanceGroups() + workspaceRoles = undefined + loadRoles() }) } }) @@ -356,20 +381,16 @@ let nbInviteDisplayed = $state(50) - async function inviteUser(email: string, selected: 'operator' | 'developer' | 'admin') { + async function inviteUser(email: string, selected: string) { try { await WorkspaceService.inviteUser({ workspace: $workspaceStore!, - requestBody: { - email, - is_admin: selected == 'admin', - operator: selected == 'operator' - } + requestBody: { email, ...roleRequestFields(selected) } }) sendUserToast(`Invited ${email}`) } catch (e) { console.error('Failed to invite user:', e) - sendUserToast('Failed to invite user', true) + sendUserToast(`Failed to invite user: ${e?.body ?? e}`, true) } if (!(await UserService.existsEmail({ email }))) { @@ -412,7 +433,7 @@ workspace: $workspaceStore ?? '', requestBody: enable ? { - operator: operatorOnly ?? false, + ...roleRequestFields(validRoleOr(roles, autoInviteRole)), invite_all: !isCloudHosted(), auto_add: autoAdd } @@ -430,7 +451,7 @@ sendUserToast(message) } catch (e) { console.error('Failed to update auto invite:', e) - sendUserToast('Failed to update auto invite', true) + sendUserToast(`Failed to update auto invite: ${e?.body ?? e}`, true) } loadSettings() listInvites() @@ -548,32 +569,18 @@ {/if} Role Role of the auto-added users - { - operatorOnly = e.detail === 'operator' + { + autoInviteRole = v if (auto_invite_domain != undefined) { await updateAutoInvite(true) } }} - > - {#snippet children({ item })} - - - {/snippet} - + />
Role - { - selectedNewRole = e.detail - }} - > - {#snippet children({ item })} - - - - {/snippet} - + (selectedNewRole = v)} + />
@@ -697,7 +680,7 @@ color="blue" size="xs" startIcon={{ icon: Plus }} - disabled={!selectedNewInstanceGroup || !selectedNewRole} + disabled={!selectedNewInstanceGroup} onclick={addInstanceGroup} > Add @@ -730,38 +713,15 @@
- { - autoAddInstanceGroupsRoles[groupName] = e.detail - await updateGroupRole(groupName, e.detail) + { + await updateGroupRole(groupName, v) }} - > - {#snippet children({ item })} - - - - {/snippet} - + />
@@ -800,10 +760,11 @@ {/if} {#if showAutoInviteToggle} - + {/if} { listUsers() listInvites() @@ -851,7 +812,7 @@ {#if filteredUsers} {#each sortedUsers().slice(0, nbDisplayed) as user, index (user.email)} - {@const { email, username, is_admin, operator, disabled, added_via } = user} + {@const { email, username, is_admin, disabled, added_via } = user} {#if hasNonManualUsers && index > 0 && sortedUsers()[index - 1]?.added_via?.source !== 'instance_group' && added_via?.source === 'instance_group'} @@ -901,63 +862,35 @@ {#if added_via?.source === 'instance_group'}
- {is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} + {roleLabel(roles, pickerValue(user))} Role is managed through instance group configuration above.
{:else} - { - if (is_admin && email == $userStore?.email && e.detail != 'admin') { + { + if (is_admin && email == $userStore?.email && v != 'admin') { sendUserToast( 'Admins cannot be demoted by themselves, ask another admin to demote you', true ) - e.preventDefault() listUsers() return } - const body = - e.detail == 'admin' - ? { is_admin: true, operator: false } - : e.detail == 'operator' - ? { is_admin: false, operator: true } - : { is_admin: false, operator: false } - await UserService.updateUser({ - workspace: $workspaceStore ?? '', - username, - requestBody: body - }) - listUsers() + try { + await UserService.updateUser({ + workspace: $workspaceStore ?? '', + username, + requestBody: roleRequestFields(v) + }) + } catch (e) { + sendUserToast(`Failed to change role: ${e?.body ?? e}`, true) + } + await listUsers() }} - > - {#snippet children({ item })} - - - - - - {/snippet} - + /> {/if} @@ -1083,7 +1016,9 @@
- +{#if $workspaceStore} + +{/if}
@@ -1118,7 +1053,7 @@ {#snippet action()} {#if showAutoInviteToggle && !isAdminsWorkspaceWithoutEE}
- +
{/if} {/snippet} @@ -1132,56 +1067,27 @@ {#if invites?.length > 0} - {#each invites.slice(0, nbInviteDisplayed) as { email, is_admin, operator }} + {#each invites.slice(0, nbInviteDisplayed) as invite} + {@const { email, is_admin, operator } = invite} {email}
- { - const body = - e.detail == 'admin' - ? { is_admin: true, operator: false } - : e.detail == 'operator' - ? { is_admin: false, operator: true } - : { is_admin: false, operator: false } - await WorkspaceService.inviteUser({ - workspace: $workspaceStore ?? '', - requestBody: { - email, - ...body - } - }) - listUsers() + { + try { + await WorkspaceService.inviteUser({ + workspace: $workspaceStore ?? '', + requestBody: { email, ...roleRequestFields(v) } + }) + } catch (e) { + sendUserToast(`Failed to change role: ${e?.body ?? e}`, true) + } + await listInvites() }} - > - {#snippet children({ item })} - - - - - - {/snippet} - + />
diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index 95dc03992c..13f38840d4 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -38,7 +38,6 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' - import { sidebarPageAllowed } from './operatorRoutes' import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, @@ -49,7 +48,8 @@ enterpriseLicense, isCriticalAlertsUIOpen, usageStore, - isPremiumStore + isPremiumStore, + roleCapabilities } from '$lib/stores' let { @@ -250,7 +250,7 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, - ...($userStore?.operator + ...(!$roleCapabilities.code ? [ { displayName: 'Take the tour', @@ -318,7 +318,7 @@ label="Workers" href="/workers" icon={ServerCog} - disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')} + disabled={!$roleCapabilities.visibility.workers} {isCollapsed} aiId="sidebar-menu-link-workers" aiDescription="Button to navigate to workers" @@ -364,7 +364,7 @@ label="Audit logs" href="/audit_logs" icon={Eye} - disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')} + disabled={!$roleCapabilities.visibility.audit_logs} {isCollapsed} aiId="sidebar-menu-link-audit-logs" aiDescription="Button to navigate to audit logs" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 7187709d2c..f08446d4ed 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -8,7 +8,8 @@ workspaceStore, isCriticalAlertsUIOpen, enterpriseLicense, - devopsRole + devopsRole, + roleCapabilities } from '$lib/stores' import { isForkOwner } from '$lib/utils/workspaceHierarchy' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' @@ -65,7 +66,7 @@ import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte' import AzureIcon from '../icons/triggers/AzureIcon.svelte' import { leaveCurrentWorkspace } from './leaveWorkspace' - import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes' + import type { VisibilityKey } from '$lib/roleCapabilities' import { markChangelogsOpened, readRecentChangelogs } from './changelogs' const { recent: recentChangelogs, hasNew } = readRecentChangelogs() @@ -183,8 +184,8 @@ } const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) - function allows(key: OperatorPageKey) { - return sidebarPageAllowed($userStore?.operator, currentWs, key) + function allows(key: VisibilityKey) { + return $roleCapabilities.visibility[key] } const itemClass = twMerge( @@ -228,7 +229,7 @@ label: p.label, href: p.path === '/' ? `/` : `${p.path}`, icon: p.icon, - disabled: p.operatorKey ? !allows(p.operatorKey) : false, + disabled: p.visibilityKey ? !allows(p.visibilityKey) : false, ...MAIN_MENU_EXTRAS[p.label] })) .filter((l) => !excludeMainLabels.includes(l.label)) @@ -436,7 +437,7 @@ ] : []) ], - disabled: $userStore?.operator + disabled: !$roleCapabilities.code }, { label: 'Workers', diff --git a/frontend/src/lib/components/sidebar/UserMenu.svelte b/frontend/src/lib/components/sidebar/UserMenu.svelte index ff302d3c5e..40c64d5621 100644 --- a/frontend/src/lib/components/sidebar/UserMenu.svelte +++ b/frontend/src/lib/components/sidebar/UserMenu.svelte @@ -6,8 +6,11 @@ usageStore, workspaceUsageStore, isPremiumStore, - workspaceStore + workspaceStore, + userWorkspaces, + roleCapabilities } from '$lib/stores' + import { isRestricted } from '$lib/roleCapabilities' import { USER_SETTINGS_HASH } from './settings' import { isCloudHosted } from '$lib/cloud' import { twMerge } from 'tailwind-merge' @@ -32,6 +35,12 @@ // An account entered through an invite link that still has no credentials of its own. let pendingSetup = $derived(accountSetup.pending) + // A server that predates roles sends no role name, and its only restricted role is operator. + const roleName = $derived( + $userWorkspaces.find((w) => w.id === $workspaceStore)?.role ?? 'operator' + ) + const roleLabel = $derived(roleName.charAt(0).toUpperCase() + roleName.slice(1)) + const itemClass = twMerge( 'text-secondary text-left font-normal text-xs ', 'flex flex-row items-center gap-2 px-4 py-3 w-full', @@ -62,8 +71,8 @@ Superadmin, not a member of this workspace {:else if $userStore?.is_admin} Admin of this workspace - {:else if $userStore?.operator} - Operator in this workspace + {:else if isRestricted($roleCapabilities)} + {roleLabel} in this workspace {/if} {#if $userStore?.non_member} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index c70fef70a4..97385c7e9d 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -2,8 +2,10 @@ import { tick, untrack } from 'svelte' import type { MenubarMenuElements } from '@melt-ui/svelte' import { workspaceMenuHref } from './workspaceMenuHref' - import { isOperatorInWorkspace } from './operatorRoutes' + import { capabilitiesIn, hidesPages, isRestricted } from '$lib/roleCapabilities' + import type { RoleCapabilities } from '$lib/gen' import { + roleCapabilities, isPremiumStore, maybePremium, superadmin, @@ -85,16 +87,15 @@ await fixupUrlAfterWorkspaceSwitch(id, { landOnHome }) } - // Operator page access is granted per workspace, so a switch lands on home rather than - // on a page either side may refuse. Both signals are positive: `userStore.operator` is - // an explicit flag for the workspace being left, and `isOperatorInWorkspace` only ever - // confirms the target (its NULL is ambiguous, so a `false` from it is never taken as - // proof of a developer on its own). Leaving one developer workspace for another keeps - // the page, as it always has. + // What a role may see and do is per workspace, so a switch that leaves or enters a restricted + // role lands on home rather than on a page either side may refuse. Leaving one unrestricted + // workspace for another keeps the page. function landsOnHome(id: string): boolean { if (keepPageOnSwitch) return false - if ($userStore?.operator) return true - return isOperatorInWorkspace(($userWorkspaces ?? []).find((w) => w.id === id)) + const gated = (caps: RoleCapabilities) => isRestricted(caps) || hidesPages(caps) + if (gated($roleCapabilities)) return true + const target = ($userWorkspaces ?? []).find((w) => w.id === id) + return gated(capabilitiesIn({ is_super_admin: $userStore?.is_super_admin }, target)) } // Href for the item's navigation (including modifier/middle clicks opening a @@ -160,7 +161,7 @@ // modal carries its own base-workspace picker). Hidden on non-premium cloud, // in the admins workspace, or when forking is disabled. const canForkHere = $derived( - (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore) + (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore, $roleCapabilities) ) // The row mechanics below — which family to expand, which collapsed row carries the tick — key on // a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts index 19568eaddc..e53f4e7cbb 100644 --- a/frontend/src/lib/components/sidebar/navDetached.svelte.ts +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -1,5 +1,5 @@ import { fromStore } from 'svelte/store' -import { userStore } from '$lib/stores' +import { roleCapabilities } from '$lib/stores' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' // One preference per role rather than one per account. The same person is both — an operator in @@ -8,21 +8,21 @@ import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' // Empty until that role presses detach or attach once. const operatorPref = useLocalStorageValue('nav_detached_operator', '', 'string') const devPref = useLocalStorageValue('nav_detached_dev', '', 'string') -const user = fromStore(userStore) +const caps = fromStore(roleCapabilities) /** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ export const navDetached = { get val(): boolean { // Unasked, operators start detached: many of them navigate through a workspace's own app, // and a docked sidebar would take its width. - const operator = !!user.current?.operator + const operator = !caps.current.code const pref = operator ? operatorPref : devPref if (pref.val === 'true') return true if (pref.val === 'false') return false return operator }, set val(detached: boolean) { - const pref = user.current?.operator ? operatorPref : devPref + const pref = caps.current.code ? devPref : operatorPref pref.val = String(detached) } } diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts index 385b93abcc..2461ba6e1a 100644 --- a/frontend/src/lib/components/sidebar/navPages.ts +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -11,7 +11,7 @@ import { Unplug, Users } from 'lucide-svelte' -import type { OperatorPageKey } from './operatorRoutes' +import type { VisibilityKey } from '$lib/roleCapabilities' /** * The names the page header's breadcrumb gives the workspace's own pages, and the rows the @@ -20,7 +20,7 @@ import type { OperatorPageKey } from './operatorRoutes' * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so - * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * for those four the `visibilityKey` below goes unread. Keep the labels in step with those rows by * hand. */ export type NavPage = { @@ -28,23 +28,23 @@ export type NavPage = { /** Path under `base`, matched against the current route by longest prefix. */ path: string icon: any - /** The `operator_settings` key that admits an operator, when the page has one. */ - operatorKey?: OperatorPageKey + /** The role visibility flag that shows the page, when the page has one. */ + visibilityKey?: VisibilityKey } export const NAV_PAGES: NavPage[] = [ { label: 'Home', path: '/', icon: Home }, - { label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' }, - { label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' }, - { label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' }, - { label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' }, - { label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' }, - { label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' }, - { label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' }, + { label: 'Runs', path: '/runs', icon: Play, visibilityKey: 'runs' }, + { label: 'Variables', path: '/variables', icon: DollarSign, visibilityKey: 'variables' }, + { label: 'Resources', path: '/resources', icon: Boxes, visibilityKey: 'resources' }, + { label: 'Assets', path: '/assets', icon: Pyramid, visibilityKey: 'assets' }, + { label: 'Folders', path: '/folders', icon: FolderOpen, visibilityKey: 'folders' }, + { label: 'Groups', path: '/groups', icon: Users, visibilityKey: 'groups' }, + { label: 'Schedules', path: '/schedules', icon: Calendar, visibilityKey: 'schedules' }, // "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP. - { label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' }, - { label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' }, - { label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' } + { label: 'HTTP routes', path: '/routes', icon: Unplug, visibilityKey: 'triggers' }, + { label: 'Workers', path: '/workers', icon: HardHat, visibilityKey: 'workers' }, + { label: 'Audit logs', path: '/audit_logs', icon: Eye, visibilityKey: 'audit_logs' } ] /** diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts deleted file mode 100644 index bf14926d00..0000000000 --- a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { sidebarPageAllowed } from './operatorRoutes' -import type { UserWorkspace } from '$lib/stores' - -const ws = (operator_settings: any): UserWorkspace => - ({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace - -describe('sidebarPageAllowed', () => { - it('shows every page to anyone who is not an operator', () => { - expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true) - expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true) - }) - - it('admits an operator only where the workspace turned the page on', () => { - expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true) - expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false) - }) - - it('withholds a page the settings do not mention', () => { - // `=== true`, not truthiness: a right absent from the jsonb, or settings that were never - // written, must not read as granted. - expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false) - expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false) - expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false) - }) -}) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts deleted file mode 100644 index 6824c36c62..0000000000 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { OperatorSettings } from '$lib/gen' -import type { UserWorkspace } from '$lib/stores' - -/** - * An `operator_settings` key that admits an operator to one page (`triggers` to all of them). - * - * Only the visibility flags: the same object also carries the write rights `builder_flows`, - * `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A - * write right is granted unless withdrawn, so its absence means "never configured" rather than - * "no" — see `docs/operator-write-rights.md`. - */ -export type OperatorPageKey = Extract< - keyof NonNullable, - | 'runs' - | 'schedules' - | 'resources' - | 'variables' - | 'assets' - | 'audit_logs' - | 'triggers' - | 'groups' - | 'folders' - | 'workers' -> - -/** - * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page - * (other gates, such as admin-only entries, still apply); an operator sees only the pages the - * workspace's `operator_settings` turn on. - */ -export function sidebarPageAllowed( - isOperator: boolean | undefined, - workspace: UserWorkspace | undefined, - key: OperatorPageKey -): boolean { - return !isOperator || workspace?.operator_settings?.[key] === true -} - -/** - * Whether the user is an operator in `workspace`. The server nulls - * `operator_settings` for a non-operator, so a non-null value narrows an operator - * correctly; the converse does not hold — the column is itself nullable, so a - * genuine operator whose workspace never had settings written also reads as NULL. - * Only rely on `true`, never on `false` meaning "not an operator". - */ -export function isOperatorInWorkspace(workspace: UserWorkspace | undefined): boolean { - return workspace?.operator_settings != null -} diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte index 1ccbd21d0e..3931d9660c 100644 --- a/frontend/src/lib/components/triggers/TriggerList.svelte +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -22,7 +22,7 @@ } from '$lib/utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' import { getLocalDraftHint } from '$lib/localDraftHints.svelte' - import { enterpriseLicense, usedTriggerKinds, userWorkspaces, workspaceStore } from '$lib/stores' + import { enterpriseLicense, usedTriggerKinds, workspaceStore } from '$lib/stores' import { goto, setQuery } from '$lib/navigation' import { isCloudHosted } from '$lib/cloud' import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable' @@ -519,10 +519,7 @@ } const operatorBlocked = $derived( - config.operatorGate && - !!operatingUser.current?.operator && - !!$operatingWorkspace && - !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.triggers + config.operatorGate && !!$operatingWorkspace && !editRights.capabilities.visibility.triggers ) const canCreate = $derived(!config.adminOnly || isAdmin) @@ -883,7 +880,7 @@ {#if operatorBlocked} {:else} diff --git a/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte b/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte index c6b0986226..9a954b211e 100644 --- a/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte +++ b/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte @@ -15,7 +15,8 @@ scriptPath: string initialScriptPath: string canWrite: boolean - isOperator: boolean + /** Whether to link to the picked runnable's editor: the role must author its kind. */ + allowEdit: boolean promptText?: string promptClass?: string workspace?: string @@ -30,7 +31,7 @@ scriptPath = $bindable(), initialScriptPath, canWrite, - isOperator, + allowEdit, promptText = 'Pick a script or flow to be triggered', promptClass = 'text-xs mb-1 text-primary', workspace = undefined, @@ -53,7 +54,7 @@ bind:itemKind bind:scriptPath allowRefresh={canWrite} - allowEdit={!isOperator} + {allowEdit} {workspace} clearable /> diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte index 7295ca0c35..c0f4803339 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte @@ -91,7 +91,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const wsId = $derived($operatingWorkspace) let amqp_resource_path: string = $state('') @@ -586,7 +585,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} /> {/if} diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index e0c2008be8..6b52a05428 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -54,7 +54,6 @@ let mode = $state('enabled') let dirtyPath = $state(false) const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) let permsPath = $state(undefined) let permsForWrite = $state | undefined>(undefined) // The acting user in the operating workspace arrives asynchronously, and an unknown user @@ -526,7 +525,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 38a8243855..9de47ba600 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -455,7 +455,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptClass="text-xs mt-3 mb-1 text-primary" > {#snippet createButton()} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 486711bd6c..04052a54d6 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -63,7 +63,6 @@ let mode = $state('enabled') let dirtyPath = $state(false) const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) let permsPath = $state(undefined) let permsForWrite = $state | undefined>(undefined) // The acting user in the operating workspace arrives asynchronously, and an unknown user @@ -599,7 +598,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptText="Pick a script or flow to be triggered " > {#snippet createButton()} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 786137eab4..fe94787167 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -778,7 +778,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 17a17f98e8..80be6049d3 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -88,7 +88,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -609,7 +608,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} > {#snippet createButton()} {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 5c1fb4aaf0..d58abebe3b 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -96,7 +96,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -582,7 +581,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} > {#snippet createButton()} {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index 9e8dc592e9..3246b12a4d 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -28,15 +28,10 @@ import { deepEqual } from 'fast-equals' import type { Snippet } from 'svelte' import Alert from '$lib/components/common/alert/Alert.svelte' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const editRights = useEditRights() interface Props { @@ -540,7 +535,7 @@ bind:itemKind kinds={['script']} allowFlow={true} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> {#if emptyString(scriptPath)} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index e0ba992e12..db95d73e7f 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -84,7 +84,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -578,7 +577,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} > {#snippet createButton()} {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 68ee8816b7..402cca53fb 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -103,7 +103,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -760,7 +759,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptText="Pick a script or flow to be triggered " promptClass="text-xs text-primary" > diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 48227dea44..5e167a4337 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -141,9 +141,9 @@ ? editRights.roleCanAuthor('schedule') : editRights.canEditItem('schedule', permsPath, extraPerms) ) - // Editing the runnable is closed to operators, and an unresolved acting user is no - // evidence that this one isn't. - const canEditRunnable = $derived(actingUser !== undefined && !actingUser.operator) + // Editing the runnable needs a role that authors its kind; an unresolved acting user is no + // evidence of that role. + const canEditRunnable = $derived(actingUser !== undefined && editRights.roleCanAuthor(itemKind)) const saveDisabled = $derived( !allowSchedule || pathError != '' || diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 5593dd121f..77f9478026 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -88,7 +88,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -561,7 +560,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptText="Pick a script or flow to be triggered " > {#snippet createButton()} diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index 7fe40d111d..8ee12aaaea 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -32,6 +32,7 @@ import type { UserExt } from '$lib/stores' import type { ScheduleTrigger } from '../triggers' import { formatCron, generateRandomString } from '$lib/utils' import { canEditItem } from '$lib/editRights' +import { capabilitiesIn } from '$lib/roleCapabilities' export class Triggers { #triggers = $state([]) @@ -135,9 +136,10 @@ export class Triggers { user: UserExt | undefined = undefined ): number { const currentTriggers = this.#triggers - const operatorSettings = get(userWorkspaces).find( - (w) => w.id === workspaceId - )?.operator_settings + const caps = capabilitiesIn( + user, + get(userWorkspaces).find((w) => w.id === workspaceId) + ) const kind = type === 'schedule' ? 'schedule' : 'trigger' // Identify triggers with draftConfig to preserve const configuredTriggers = currentTriggers.filter( @@ -157,7 +159,7 @@ export class Triggers { path: trigger.path, isPrimary: type === 'schedule' && trigger.path === trigger.script_path, isDraft: false, - canWrite: canEditItem(kind, trigger.path, trigger.extra_perms, user, operatorSettings), + canWrite: canEditItem(kind, trigger.path, trigger.extra_perms, user, caps), draftConfig: draftConfig, lightConfig: getLightConfig(type, trigger) } diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index b011d6fee1..5d19535b01 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -105,7 +105,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -637,7 +636,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/variables/VariablesList.svelte b/frontend/src/lib/components/variables/VariablesList.svelte index 3805b51bbb..e3d996804e 100644 --- a/frontend/src/lib/components/variables/VariablesList.svelte +++ b/frontend/src/lib/components/variables/VariablesList.svelte @@ -36,7 +36,7 @@ import Tooltip from '$lib/components/Tooltip.svelte' import VariableEditor from '$lib/components/VariableEditor.svelte' import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen' - import { enterpriseLicense, userWorkspaces } from '$lib/stores' + import { enterpriseLicense } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { isOwner, truncate } from '$lib/utils' import { useEditRights } from '$lib/operatorWriteRights' @@ -317,10 +317,10 @@ -{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables} +{#if $operatingWorkspace && !editRights.capabilities.visibility.variables} {:else} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index ed96f56811..d6a43364f0 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -34,7 +34,7 @@ devWorkspacesInChainAbove } from '$lib/utils/workspaceHierarchy' import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' - import { isOperatorInWorkspace } from '$lib/components/sidebar/operatorRoutes' + import { capabilitiesIn, isRestricted } from '$lib/roleCapabilities' import { canUserBypassRuleKindInRulesets, fetchProtectionRulesForWorkspace, @@ -138,14 +138,14 @@ let forkableWorkspaces = $derived(forkable.current) // Base candidates are the current workspace's family: its root first, then every fork/dev under it. - // Never one the user is an operator in: a fork does not carry the role, so forking such a base + // Never one the user holds a restricted role in: a fork does not carry the role, so forking such a base // would hand them a full-rights copy of it. let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces)) let baseCandidates = $derived( (familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)] : [] - ).filter((w) => !isOperatorInWorkspace(w)) + ).filter((w) => !isRestricted(capabilitiesIn(undefined, w))) ) let baseItems = $derived( baseCandidates.map((w) => ({ diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index a6d36f693c..fa3366a284 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import type { RoleCapabilities } from '$lib/gen' import type { UserExt } from '$lib/stores' +import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities' import { canEditItem, roleCanAuthor, roleCanDraft } from './editRights' function user(overrides: Partial): UserExt { @@ -20,40 +22,49 @@ function user(overrides: Partial): UserExt { } as UserExt } +const role = (overrides: Partial): RoleCapabilities => ({ + ...legacyOperatorCapabilities(undefined), + ...overrides +}) + describe('edit rights', () => { - it('lets a builder operator author flows and nothing else', () => { - const builder = { builder_flows: true } - expect(roleCanAuthor('flow', user({}), builder)).toBe(true) - expect(roleCanAuthor('script', user({}), builder)).toBe(false) - expect(roleCanAuthor('flow', user({}), undefined)).toBe(false) - expect(roleCanDraft('raw_app', user({}), builder)).toBe(false) - expect(roleCanDraft('raw_app', user({}), { builder_apps: true })).toBe(true) + it('lets a compose-only flow role author flows and nothing else', () => { + const builder = role({ flows: 'compose' }) + expect(roleCanAuthor('flow', builder)).toBe(true) + expect(roleCanAuthor('script', builder)).toBe(false) + expect(roleCanAuthor('raw_app', builder)).toBe(false) + expect(roleCanDraft('raw_app', builder)).toBe(false) + expect(roleCanDraft('raw_app', role({ apps: 'compose' }))).toBe(true) }) - // 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) + // apps.rs `require_full_apps`: a composing role builds full-code apps, never low-code ones. + it('keeps low-code apps for full app authors', () => { + expect(roleCanAuthor('app', role({ apps: 'compose' }))).toBe(false) + expect(roleCanAuthor('raw_app', role({ apps: 'compose' }))).toBe(true) + const scripter = { ...FULL_CAPABILITIES, apps: 'none' as const } + expect(roleCanAuthor('script', scripter)).toBe(true) + expect(roleCanAuthor('app', scripter)).toBe(false) + expect(roleCanAuthor('raw_app', scripter)).toBe(false) }) - // drafts.rs admits an operator's draft of a kind they could deploy, and no other. - it('lets an operator draft schedules and triggers until the right is withdrawn', () => { - expect(roleCanDraft('schedule', user({}), undefined)).toBe(true) - expect(roleCanDraft('trigger', user({}), undefined)).toBe(true) - expect(roleCanDraft('schedule', user({}), { manage_schedules: false })).toBe(false) - expect(roleCanDraft('trigger', user({}), { manage_triggers: false })).toBe(false) + // drafts.rs admits a restricted role's draft of a kind it could deploy, and no other. + it('lets a role draft schedules and triggers until the right is withdrawn', () => { + expect(roleCanDraft('schedule', role({}))).toBe(true) + expect(roleCanDraft('trigger', role({}))).toBe(true) + expect(roleCanDraft('schedule', role({ manage_schedules: false }))).toBe(false) + expect(roleCanDraft('trigger', role({ manage_triggers: false }))).toBe(false) }) - it('lets an operator draft resources and variables, but not scripts', () => { - expect(roleCanDraft('resource', user({}), undefined)).toBe(true) - expect(roleCanDraft('variable', user({}), undefined)).toBe(true) - expect(roleCanDraft('script', user({}), undefined)).toBe(false) + it('lets any role draft resources and variables, but scripts only with code', () => { + expect(roleCanDraft('resource', role({}))).toBe(true) + expect(roleCanDraft('variable', role({}))).toBe(true) + expect(roleCanDraft('script', role({}))).toBe(false) + expect(roleCanDraft('script', role({ code: true }))).toBe(true) }) // 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 } + const builder = role({ flows: 'compose' }) expect(canEditItem('flow', 'f/demo/sum', {}, user({}), builder)).toBe(false) expect(canEditItem('flow', 'u/op/sum', {}, user({}), builder)).toBe(true) }) diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index 888b61e818..48ec6c3ee2 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -1,16 +1,17 @@ -import type { OperatorSettings } from '$lib/gen' +import type { RoleCapabilities } from '$lib/gen' import type { UserExt } from '$lib/stores' import { canWrite } from '$lib/utils' +import { isRestricted } from '$lib/roleCapabilities' /** * The frontend's one copy of "may this user edit this item". The server decides — drafts.rs - * `require_can_write_path`, the item handlers' operator refusals, `gate_operator_writes`, and + * `require_can_write_path`, the item handlers' role refusals, `gate_operator_writes`, and * row-level security — and this mirrors it so a control the server would refuse is never * offered. Check edits through `canEditItem` (or `useEditRights` in a component), never by - * pairing `canWrite` with an operator test of your own. + * pairing `canWrite` with a role test of your own. */ -/** Kinds whose role half differs. Any other kind is code an operator never authors. */ +/** Kinds whose role half differs. Any other kind is code, authored under `code`. */ export type EditKind = | 'script' | 'flow' @@ -25,82 +26,45 @@ export type EditKind = | 'group' | (string & {}) -type OperatorRights = - | Pick< - NonNullable, - 'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers' - > - | null - | undefined - /** - * Whether a user's role lets them author items of `kind` — create, edit, deploy or delete one — - * in a workspace with these `operator_settings`: the role half of every check against the item - * handlers. Saving a draft is `roleCanDraft`'s rule, and which items they may write is - * `canEditItem`'s other half. For an operator: - * - code (scripts, low-code apps, agents, pipelines): never; - * - flows and full-code apps: where the workspace grants `builder_flows` / `builder_apps` - * (`check_operator_can_build`); - * - schedules and triggers: unless the workspace set `manage_*` to false — a withdrawable - * right, so an unset key grants it (docs/operator-write-rights.md); + * Whether a role with `caps` lets its member author items of `kind` (create, edit, deploy or + * delete one): the role half of every check against the item handlers. Saving a draft is + * `roleCanDraft`'s rule, and which items they may write is `canEditItem`'s other half. + * - code (scripts, agents, pipelines): `code`; + * - flows and full-code apps: `compose` or above (`check_operator_can_build`); + * - low-code apps: `full` apps only (apps.rs `require_full_apps`); + * - schedules and triggers: the matching manage right; * - resources, variables and groups: always, their permissions alone decide. */ -export function roleCanAuthor( - kind: EditKind, - user: Pick | undefined, - operatorSettings: OperatorRights -): boolean { - if (!user?.operator) return true +export function roleCanAuthor(kind: EditKind, caps: RoleCapabilities): boolean { switch (kind) { case 'flow': + return caps.flows !== 'none' case 'raw_app': - return builderGrants(kind, operatorSettings) + return caps.apps !== 'none' + case 'app': + return caps.apps === 'full' case 'schedule': - return operatorSettings?.manage_schedules !== false + return caps.manage_schedules case 'trigger': - return operatorSettings?.manage_triggers !== false + return caps.manage_triggers case 'resource': case 'variable': case 'group': return true default: - return false + return caps.code } } /** - * Whether the user may save drafts of `kind` — what every session editor and the chat's draft - * tools write. Mirrors drafts.rs `require_can_write_path`, which differs from the item handlers - * both ways: it admits an admin before its operator branch, and an operator drafts only the kinds - * they could deploy — resources and variables always, flows and full-code apps under their builder - * right, schedules and triggers under the manage rights. + * Whether the role may save drafts of `kind`, which every session editor and the chat's draft + * tools write. Mirrors drafts.rs `operator_can_draft`: a restricted role drafts only the kinds it + * could deploy, and resources and variables always. An unrestricted role drafts every kind. */ -export function roleCanDraft( - kind: EditKind, - user: Pick | undefined, - operatorSettings: OperatorRights -): boolean { - if (user?.is_admin || user?.is_super_admin || !user?.operator) return true - switch (kind) { - case 'flow': - case 'raw_app': - return builderGrants(kind, operatorSettings) - case 'schedule': - case 'trigger': - return roleCanAuthor(kind, user, operatorSettings) - case 'resource': - case 'variable': - return true - default: - return false - } -} - -function builderGrants(kind: EditKind, operatorSettings: OperatorRights): boolean { - return ( - (kind === 'flow' && operatorSettings?.builder_flows === true) || - (kind === 'raw_app' && operatorSettings?.builder_apps === true) - ) +export function roleCanDraft(kind: EditKind, caps: RoleCapabilities): boolean { + if (!isRestricted(caps)) return true + return kind === 'group' ? false : roleCanAuthor(kind, caps) } /** `roleCanDraft` for the item at `path`, with its own permissions as in `canEditItem`. */ @@ -109,9 +73,9 @@ export function canDraftItem( path: string, extraPerms: Record | undefined, user: UserExt | undefined, - operatorSettings: OperatorRights + caps: RoleCapabilities ): boolean { - return roleCanDraft(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user) + return roleCanDraft(kind, caps) && canWrite(path, extraPerms ?? {}, user) } /** Whether `user` may edit the item at `path`: their role authors `kind`, and the item's own @@ -121,7 +85,7 @@ export function canEditItem( path: string, extraPerms: Record | undefined, user: UserExt | undefined, - operatorSettings: OperatorRights + caps: RoleCapabilities ): boolean { - return roleCanAuthor(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user) + return roleCanAuthor(kind, caps) && canWrite(path, extraPerms ?? {}, user) } diff --git a/frontend/src/lib/operatorWriteRights.ts b/frontend/src/lib/operatorWriteRights.ts index 916e39dce6..28a68784e6 100644 --- a/frontend/src/lib/operatorWriteRights.ts +++ b/frontend/src/lib/operatorWriteRights.ts @@ -1,29 +1,38 @@ import { derived, fromStore, get, readable, type Readable } from 'svelte/store' -import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' +import type { RoleCapabilities } from '$lib/gen' +import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { useActingUser } from '$lib/actingUser.svelte' import { canDraftItem, canEditItem, roleCanAuthor, roleCanDraft } from '$lib/editRights' +import { capabilitiesIn } from '$lib/roleCapabilities' /** * The `editRights` rules (`roleCanAuthor` / `canEditItem` for the item handlers, `roleCanDraft` / - * `canDraftItem` for drafts) answered for the user acting in the operating workspace, - * with that workspace's `operator_settings` — or in `workspace`, for a component that is handed - * one rather than sitting under a host that declares it. Reads context and registers an effect: - * call during component initialisation. + * `canDraftItem` for drafts) answered for the user acting in the operating workspace, with their + * role's capabilities there, or in `workspace`, for a component that is handed one rather than + * sitting under a host that declares it. Reads context and registers an effect: call during + * component initialisation. */ export function useEditRights(workspace?: () => string | undefined) { const operating = fromStore(useOperatingWorkspace()) const ws = workspace ?? (() => operating.current) const user = useActingUser(ws) const workspaces = fromStore(userWorkspaces) - const settings = () => workspaces.current.find((w) => w.id === ws())?.operator_settings + const caps = () => + capabilitiesIn( + user.current, + workspaces.current.find((w) => w.id === ws()) + ) return { - roleCanAuthor: (kind: string) => roleCanAuthor(kind, user.current, settings()), + get capabilities(): RoleCapabilities { + return caps() + }, + roleCanAuthor: (kind: string) => roleCanAuthor(kind, caps()), canEditItem: (kind: string, path: string, extraPerms: Record | undefined) => - canEditItem(kind, path, extraPerms, user.current, settings()), - roleCanDraft: (kind: string) => roleCanDraft(kind, user.current, settings()), + canEditItem(kind, path, extraPerms, user.current, caps()), + roleCanDraft: (kind: string) => roleCanDraft(kind, caps()), canDraftItem: (kind: string, path: string, extraPerms: Record | undefined) => - canDraftItem(kind, path, extraPerms, user.current, settings()), + canDraftItem(kind, path, extraPerms, user.current, caps()), /** 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() @@ -31,20 +40,42 @@ export function useEditRights(workspace?: () => string | undefined) { } /** - * Why writes of this kind are locked in the operating workspace, or `undefined` when they are not — + * The role's capabilities in `workspace`, from the workspace list alone: `$userStore` describes + * only the navigation workspace, so elsewhere only superadmin carries over. Admins get full + * capabilities from the server's list. + */ +function capabilitiesStore(workspace: Readable): Readable { + return derived( + [userStore, workspaceStore, userWorkspaces, workspace], + ([$user, $nav, $workspaces, $ws]) => + capabilitiesIn( + $ws === $nav && $user?.workspace_id === $ws + ? $user + : { is_super_admin: $user?.is_super_admin }, + $workspaces.find((w) => w.id === $ws) + ) + ) +} + +/** The role's capabilities in the operating workspace. Reads context: call during component + * initialisation. Code outside any component reads `roleCapabilities` (navigation workspace) or + * `capabilitiesInWorkspace`. */ +export const useRoleCapabilities = () => capabilitiesStore(useOperatingWorkspace()) + +/** The role's capabilities in a given workspace, read once: for code acting on an explicit + * workspace rather than the nav's, such as the global AI chat. */ +export const capabilitiesInWorkspace = (workspace: string | undefined) => + get(capabilitiesStore(readable(workspace))) + +/** + * Why writes of this kind are locked in the operating workspace, or `undefined` when they are not: * the shape `title` and `disabled` both want. See `docs/operator-write-rights.md`. - * - * The role half of `roleCanAuthor`, phrased for the controls that explain it. `operator_settings` - * is non-null exactly for an operator (`isOperatorInWorkspace`), so it stands in for the user. */ function writeLock(kind: 'schedule' | 'trigger', noun: string) { - return derived([userWorkspaces, useOperatingWorkspace()], ([$userWorkspaces, $workspace]) => { - const settings = $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings + return derived(useRoleCapabilities(), (caps) => // Worded as the server words its refusal of the same write. - return roleCanAuthor(kind, { operator: settings != null }, settings) - ? undefined - : `Operators cannot manage ${noun} in this workspace` - }) + roleCanAuthor(kind, caps) ? undefined : `Your role cannot manage ${noun} in this workspace` + ) } /** Reads context: call during component initialisation. */ @@ -52,55 +83,9 @@ export const useScheduleLock = () => writeLock('schedule', 'schedules') /** Reads context: call during component initialisation. */ export const useTriggerLock = () => writeLock('trigger', 'triggers') -/** - * True when the user is an operator of `workspace` and it granted operators the right to compose - * this kind out of runnables that are already deployed. They still author no code, and everywhere - * else `operator` keeps meaning read-only, so a gate on the operator role has to consult this before - * refusing. `operator_settings` is null for a non-operator, so this is false for them. - * - * The two rights are granted independently: anything that authors one kind must gate on that - * kind, or a flows-only workspace offers app affordances the backend then refuses. - */ -function builderRight( - key: 'builder_flows' | 'builder_apps', - workspace: Readable -): Readable { - return derived( - [userWorkspaces, workspace], - ([$userWorkspaces, $workspace]) => - $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings?.[key] === true - ) -} - -/** Either right, only for surfaces that are not per-kind, such as the create menu's visibility. */ -function anyBuilderRight(workspace: Readable): Readable { - return derived( - [builderRight('builder_flows', workspace), builderRight('builder_apps', workspace)], - ([flows, apps]) => flows || apps - ) -} - -/** Builder rights in the operating workspace. Reads context: call during component - * initialisation. */ -export const useOperatorBuilderFlows = () => builderRight('builder_flows', useOperatingWorkspace()) -/** Reads context: call during component initialisation. */ -export const useOperatorBuilderApps = () => builderRight('builder_apps', useOperatingWorkspace()) -/** Reads context: call during component initialisation. */ -export const useOperatorBuilderRights = () => anyBuilderRight(useOperatingWorkspace()) - -/** Builder rights in the navigation workspace, for code outside any component: the legacy AI - * chat is one instance for the whole app and acts on the workspace the nav is on. */ -export const navigationOperatorBuilderFlows = builderRight('builder_flows', workspaceStore) -export const navigationOperatorBuilderApps = builderRight('builder_apps', workspaceStore) -export const navigationOperatorBuilderRights = anyBuilderRight(workspaceStore) - -/** The app builder right in a given workspace, read once: for code acting on an explicit - * workspace rather than the nav's, such as the global AI chat. */ -export const operatorBuilderAppsIn = (workspace: string | undefined) => - get(builderRight('builder_apps', readable(workspace))) - -/** Resolves once the workspace list has loaded. Every builder right reads false until then, so - * code that picks what a builder starts with on a fresh page load has to wait for it. */ +/** Resolves once the workspace list has loaded. Capabilities fall back to the user's legacy + * operator flag until then, so code that picks what a builder starts with on a fresh page load + * has to wait for it. */ export async function workspaceListLoaded(): Promise { let unsubscribe = () => {} await new Promise((resolve) => { diff --git a/frontend/src/lib/roleCapabilities.test.ts b/frontend/src/lib/roleCapabilities.test.ts new file mode 100644 index 0000000000..1962414042 --- /dev/null +++ b/frontend/src/lib/roleCapabilities.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { RoleCapabilities } from '$lib/gen' +import { capabilitiesIn, FULL_CAPABILITIES, hidesPages, isRestricted } from './roleCapabilities' + +const scripter: RoleCapabilities = { + ...FULL_CAPABILITIES, + apps: 'none', + visibility: { ...FULL_CAPABILITIES.visibility, workers: false } +} + +describe('capabilitiesIn', () => { + it("takes the server's capabilities, whatever the legacy operator flag says", () => { + const caps = capabilitiesIn({ operator: false }, { capabilities: scripter }) + expect(caps).toEqual(scripter) + expect(isRestricted(caps)).toBe(true) + }) + + it('gives an admin everything', () => { + expect(capabilitiesIn({ is_admin: true }, { capabilities: scripter })).toBe(FULL_CAPABILITIES) + }) + + // A server that predates roles sends `operator` and `operator_settings` only. + it('derives an operator from operator_settings on an older server', () => { + const caps = capabilitiesIn( + { operator: true }, + { operator_settings: { runs: true, builder_flows: true, manage_triggers: false } as any } + ) + expect(caps.code).toBe(false) + expect(caps.flows).toBe('compose') + expect(caps.apps).toBe('none') + expect(caps.manage_schedules).toBe(true) + expect(caps.manage_triggers).toBe(false) + expect(caps.visibility.runs).toBe(true) + // `=== true`: a flag the settings never mention, or settings never written, hide the page. + expect(caps.visibility.workers).toBe(false) + expect(capabilitiesIn({ operator: true }, undefined).visibility.runs).toBe(false) + expect(capabilitiesIn({ operator: false }, undefined)).toBe(FULL_CAPABILITIES) + }) + + // `RoleCapabilities::is_restricted` leaves visibility out, so a full author keeps forking. + it('does not restrict a full role that only hides pages', () => { + const hidden = { ...FULL_CAPABILITIES, visibility: { ...FULL_CAPABILITIES.visibility, workers: false } } + expect(isRestricted(hidden)).toBe(false) + expect(hidesPages(hidden)).toBe(true) + }) +}) diff --git a/frontend/src/lib/roleCapabilities.ts b/frontend/src/lib/roleCapabilities.ts new file mode 100644 index 0000000000..55beec9ee0 --- /dev/null +++ b/frontend/src/lib/roleCapabilities.ts @@ -0,0 +1,74 @@ +import type { OperatorSettings, RoleCapabilities, RoleVisibility } from '$lib/gen' +import type { UserExt, UserWorkspace } from '$lib/stores' +import { FULL_CAPABILITIES, VISIBILITY_KEYS } from '$lib/workspaceRoles' + +/** + * What a member's workspace role lets them author, manage and see: the frontend's copy of + * `windmill_common::roles`, see `docs/workspace-roles.md`. Gate on these, never on + * `UserExt.operator`, which only says "this role cannot write code" and so lets through a role + * that may code but not, say, build apps. + */ + +export type VisibilityKey = keyof RoleVisibility + +export { FULL_CAPABILITIES } + +const VISIBILITY = VISIBILITY_KEYS.map((v) => v.key) + +/** + * The operator role as a server that predates roles describes it, through `operator_settings`: + * builder rights are `compose`, the manage rights are granted unless withdrawn, and a page is + * visible only when its flag is `true` (absent settings hide every page, as they always did). + */ +export function legacyOperatorCapabilities( + settings: OperatorSettings | null | undefined +): RoleCapabilities { + const visibility = Object.fromEntries( + VISIBILITY.map((k) => [k, settings?.[k] === true]) + ) as RoleVisibility + return { + code: false, + flows: settings?.builder_flows === true ? 'compose' : 'none', + apps: settings?.builder_apps === true ? 'compose' : 'none', + manage_schedules: settings?.manage_schedules !== false, + manage_triggers: settings?.manage_triggers !== false, + visibility + } +} + +export type CapabilitiesUser = Partial> +export type CapabilitiesWorkspace = Pick + +/** + * The capabilities of `user` in `workspace` (its entry of `$userWorkspaces`). An admin holds + * everything. The server's `capabilities` win; an older server sends none, and the role is then + * derived from `operator` and `operator_settings` the way it read them. Without a user, a + * non-null `operator_settings` is what marks an operator, as the server sends it to them only. + */ +export function capabilitiesIn( + user: CapabilitiesUser | undefined, + workspace: CapabilitiesWorkspace | undefined +): RoleCapabilities { + if (user?.is_admin || user?.is_super_admin) return FULL_CAPABILITIES + if (workspace?.capabilities) return workspace.capabilities + const operator = user?.operator ?? workspace?.operator_settings != null + return operator ? legacyOperatorCapabilities(workspace?.operator_settings) : FULL_CAPABILITIES +} + +/** Whether the role is gated at all, as `RoleCapabilities::is_restricted`: visibility is left out, + * since hiding a page must not cost a full author what restricted roles are refused, such as + * forking. */ +export function isRestricted(caps: RoleCapabilities): boolean { + return ( + !caps.code || + caps.flows !== 'full' || + caps.apps !== 'full' || + !caps.manage_schedules || + !caps.manage_triggers + ) +} + +/** Whether the role hides any page. */ +export function hidesPages(caps: RoleCapabilities): boolean { + return VISIBILITY.some((k) => !caps.visibility[k]) +} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index a3147b171c..16b62da863 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -5,6 +5,7 @@ import type { IntrospectionQuery } from 'graphql' import { CancelablePromise, type OperatorSettings, + type RoleCapabilities, type TokenResponse, type UserWorkspaceList, type Workspace, @@ -12,6 +13,7 @@ import { WorkspaceService } from './gen' import { getLocalSetting, type StateStore } from './utils' +import { capabilitiesIn } from './roleCapabilities' import { createState } from './svelte5Utils.svelte' import { DEFAULT_HUB_BASE_URL } from './hub' import type { DbManagerUriState } from './components/dbManagerDrawerModel.svelte' @@ -46,6 +48,9 @@ export interface UserWorkspace { username: string color?: string operator_settings?: OperatorSettings + /** Absent from a server that predates roles; read it through `capabilitiesIn`. */ + role?: string + capabilities?: RoleCapabilities parent_workspace_id?: string | null is_dev_workspace?: boolean dev_workspace_label?: string | null @@ -191,6 +196,18 @@ export const userWorkspaces: Readable> = derived( } ) +/** The user's role capabilities in the navigation workspace. A component acting on another + * workspace (an AI session) reads `useRoleCapabilities` instead. */ +export const roleCapabilities: Readable = derived( + [userStore, userWorkspaces, workspaceStore], + ([user, workspaces, workspace]) => + capabilitiesIn( + // Mid-switch `user` still describes the workspace being left: only superadmin carries over. + user?.workspace_id === workspace ? user : { is_super_admin: user?.is_super_admin }, + workspaces.find((w) => w.id === workspace) + ) +) + export const codeCompletionLoading = writable(false) export const metadataCompletionEnabled = writable(true) export const stepInputCompletionEnabled = writable(true) diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index d43c3644c9..9e74878f39 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -1,5 +1,8 @@ import { get } from 'svelte/store' +import { isRestricted } from '$lib/roleCapabilities' +import type { RoleCapabilities } from '$lib/gen' import { + roleCapabilities, userStore, userWorkspaces, workspaceStore, @@ -19,16 +22,17 @@ export type ItemType = 'script' | 'flow' | 'app' | 'raw_app' * Whether to show the "edit in fork / dev workspace" affordance. Allowed when the user may create a * fork, OR when the current workspace has a canonical dev to route to — routing into an existing dev * workspace creates no fork, so it survives a locked prod that has `DisableWorkspaceForking` set. - * Never for an operator, who has nothing to edit at the other end either way. User identity is read - * non-reactively (it's stable within a session); reactivity comes from the workspace args. + * Never for a role without `code`, which has nothing to edit at the other end either way. User + * identity is read non-reactively (it's stable within a session); reactivity comes from the + * workspace args. */ export function editInForkAllowed( currentWorkspaceId: string | undefined, allWorkspaces: UserWorkspace[] ): boolean { - const user = get(userStore) - if (user?.operator) return false - return canCreateFork(user) || !!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces) + const caps = get(roleCapabilities) + if (!caps.code) return false + return canCreateFork(get(userStore), caps) || !!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces) } /** Label for the affordance: "Edit in " when routed to a canonical dev, else "Edit in fork". */ @@ -54,17 +58,17 @@ export function editInForkDescription( } /** - * Whether the user may CREATE a new fork of the current workspace: not an operator, and either - * forking isn't disabled or they can bypass the rule (workspace admins). Keeps the "Fork workspace" - * entry available to admins as the last-resort escape hatch on a locked prod. + * Whether the user may CREATE a new fork of the current workspace: their role is unrestricted, and + * either forking isn't disabled or they can bypass the rule (workspace admins). Keeps the "Fork + * workspace" entry available to admins as the last-resort escape hatch on a locked prod. * - * An operator is refused because a fork does not carry the role: the server writes the forker's - * membership row from the parent's `is_admin` and lets `operator` fall to its `false` default, so - * forking would hand an operator a full-rights copy of the workspace. The server does not refuse it - * — this hides the affordance, it does not enforce the rule. + * Any restricted role is refused because a fork does not carry the role: the server writes the + * forker's membership row from the parent's `is_admin` alone, so the forker gets the default role + * and forking would hand a restricted member a full-rights copy of the workspace. The server does + * not refuse it: this hides the affordance, it does not enforce the rule. */ -export function canCreateFork(user: UserExt | undefined): boolean { - if (user?.operator) return false +export function canCreateFork(user: UserExt | undefined, caps: RoleCapabilities): boolean { + if (isRestricted(caps)) return false return ( !isRuleActive('DisableWorkspaceForking') || canUserBypassRuleKind('DisableWorkspaceForking', user) diff --git a/frontend/src/lib/utils/previewOnBehalfOf.ts b/frontend/src/lib/utils/previewOnBehalfOf.ts index 5ca4d9133c..3b28223b88 100644 --- a/frontend/src/lib/utils/previewOnBehalfOf.ts +++ b/frontend/src/lib/utils/previewOnBehalfOf.ts @@ -5,8 +5,9 @@ type ActingUser = /** * The identity an editor preview should run as: the runnable's configured run-as identity, so a * test behaves like the current deployed run. Only admins and `wm_deployers` may preview as - * someone else, and never operators (the server refuses everyone else); anyone else previews as - * themselves, which is also what their own save would record. + * someone else, and never a role without `code`, which is what `operator` carries (the server + * refuses everyone else); anyone else previews as themselves, which is also what their own save + * would record. */ export function previewOnBehalfOf( user: ActingUser, diff --git a/frontend/src/lib/utils_workspace_deploy.test.ts b/frontend/src/lib/utils_workspace_deploy.test.ts index dfac557d20..b970600ee7 100644 --- a/frontend/src/lib/utils_workspace_deploy.test.ts +++ b/frontend/src/lib/utils_workspace_deploy.test.ts @@ -239,14 +239,17 @@ describe('workspace-level deploy permission', () => { 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 () => { + // Authentication merges superadmin into admin, and `caller_capabilities` gives an admin every + // capability, whatever the workspace role. + it('lets a superadmin whose role is operator deploy', async () => { const res = await permission({ ...member, operator: true, is_super_admin: true }, []) + expect(res.ok).toBe(true) + }) + + it('refuses a member whose role cannot code', async () => { + const res = await permission({ ...member, operator: true }, []) expect(res.ok).toBe(false) - expect(res.reason).toContain('operator') + expect(res.reason).toContain('role') }) }) diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index 91820b581e..9d5b5409b8 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -22,6 +22,11 @@ import { type User } from '$lib/gen' import type { UserDraftItemKind } from '$lib/gen' +import { get } from 'svelte/store' +import { userWorkspaces } from '$lib/stores' +import { capabilitiesIn } from '$lib/roleCapabilities' +import { roleCanAuthor } from '$lib/editRights' +import type { RoleCapabilities } from '$lib/gen' import { fetchProtectionRulesForWorkspace, canUserBypassRuleKindInRulesets @@ -836,6 +841,24 @@ export function kindGatedByDeployRules(kind: Kind | UserDraftItemKind): boolean return GATED_KIND_NAMES.has(kind) } +// The kinds whose item handlers refuse a role per kind (`check_role_can_code`, +// `check_operator_can_build`, `require_full_apps`). `checkDeployPermission` only asks for `code`, +// so a role that codes but cannot author flows or apps is caught here. +const ROLE_GATED_KINDS = new Set(['script', 'flow', 'app', 'raw_app', 'data_pipeline']) + +function roleRefusal( + kind: Kind | UserDraftItemKind, + caps: RoleCapabilities +): DeployPermission | undefined { + if (!ROLE_GATED_KINDS.has(kind) || roleCanAuthor(kind === 'data_pipeline' ? 'script' : kind, caps)) + return undefined + return { + ok: false, + reason: `Your role in this workspace can't deploy ${kind.replace('_', ' ')}s`, + refusedBy: 'operator' + } +} + /** * 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 @@ -843,8 +866,11 @@ export function kindGatedByDeployRules(kind: Kind | UserDraftItemKind): boolean */ export function deployPermissionForKind( perm: DeployPermission, - kind: Kind | UserDraftItemKind + kind: Kind | UserDraftItemKind, + caps?: RoleCapabilities ): DeployPermission { + const refusal = caps && roleRefusal(kind, caps) + if (refusal) return refusal if (perm.ok) return perm if (perm.refusedBy === 'DisableDirectDeployment' && !kindGatedByDeployRules(kind)) { return { ok: true } @@ -855,8 +881,11 @@ export function deployPermissionForKind( /** 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)[] + kinds: (Kind | UserDraftItemKind)[], + caps?: RoleCapabilities ): DeployPermission { + const refusal = caps && kinds.map((k) => roleRefusal(k, caps)).find((r) => r) + if (refusal) return refusal 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. @@ -933,9 +962,9 @@ export async function checkDeployRules( /** * Whether the current user may deploy into `workspace` at all: the protection rules above, - * plus a refusal of every operator. + * plus a refusal of every role without `code`. * - * That refusal is not part of the `check_deploy_rules` mirror. The server refuses operators + * That refusal is not part of the `check_deploy_rules` mirror. The server refuses such roles * 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. A caller mirroring * one handler wants `checkDeployRules`. @@ -948,10 +977,10 @@ export async function checkDeployPermission( ): Promise { try { const me = whoami ?? (await UserService.whoami({ workspace })) - if (me.operator) { + if (!capabilitiesIn(me, get(userWorkspaces).find((w) => w.id === workspace)).code) { return { ok: false, - reason: "You're an operator in this workspace — operators can't deploy", + reason: "Your role in this workspace can't deploy", refusedBy: 'operator' } } diff --git a/frontend/src/lib/workspaceRoles.test.ts b/frontend/src/lib/workspaceRoles.test.ts new file mode 100644 index 0000000000..84f3ff8a71 --- /dev/null +++ b/frontend/src/lib/workspaceRoles.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import type { EffectiveRole } from '$lib/gen' +import { + applyCapabilitiesPatch, + baseCapabilitiesFor, + FULL_CAPABILITIES, + validRoleOr, + workspaceRolesErrors +} from './workspaceRoles' + +// These mirror `windmill_common::roles`; the server refuses what they let through. +describe('role capability layering', () => { + it('falls back to full for developer and to the operator rights for any other name', () => { + expect(baseCapabilitiesFor('developer')).toEqual(FULL_CAPABILITIES) + const other = baseCapabilitiesFor('reviewer') + expect([other.code, other.flows, other.manage_schedules]).toEqual([false, 'none', true]) + }) + + it('applies only the keys a patch sets', () => { + const caps = applyCapabilitiesPatch(baseCapabilitiesFor('x'), { + flows: 'compose', + visibility: { workers: false } + }) + expect(caps.flows).toBe('compose') + expect(caps.apps).toBe('none') + expect(caps.visibility.workers).toBe(false) + expect(caps.visibility.runs).toBe(true) + }) + + it('treats a stored null as unset, as serde reads it', () => { + const caps = applyCapabilitiesPatch(baseCapabilitiesFor('developer'), { + code: null as any, + visibility: { runs: null as any } + }) + expect(caps).toEqual(FULL_CAPABILITIES) + expect( + workspaceRolesErrors([{ name: 'developer', capabilities: { code: null as any } }]) + ).toEqual({}) + }) + + it('refuses what parse_instance_roles refuses', () => { + expect(workspaceRolesErrors(undefined)).toEqual({}) + expect(workspaceRolesErrors([])).toHaveProperty('list') + expect(workspaceRolesErrors([{ name: 'admin' }])).toHaveProperty('0.name') + expect(workspaceRolesErrors([{ name: 'a' }, { name: 'a' }])).toHaveProperty('1.name') + expect(workspaceRolesErrors([{ name: 'x', capabilities: { flows: 'full' } }])).toHaveProperty( + '0.capabilities' + ) + expect( + workspaceRolesErrors([{ name: 'developer', capabilities: { apps: 'compose' } }]) + ).toEqual({}) + }) +}) + +describe('validRoleOr', () => { + const role = (name: string) => ({ name }) as EffectiveRole + + it('falls back to developer, or the first role when the instance dropped it', () => { + expect(validRoleOr([role('operator'), role('developer')], undefined)).toBe('developer') + expect(validRoleOr([role('builder'), role('viewer')], undefined)).toBe('builder') + expect(validRoleOr([role('builder'), role('viewer')], 'developer')).toBe('builder') + expect(validRoleOr([role('builder')], 'admin')).toBe('admin') + }) +}) diff --git a/frontend/src/lib/workspaceRoles.ts b/frontend/src/lib/workspaceRoles.ts new file mode 100644 index 0000000000..4cfb3a6089 --- /dev/null +++ b/frontend/src/lib/workspaceRoles.ts @@ -0,0 +1,218 @@ +import { + WorkspaceService, + type AuthorLevel, + type EffectiveRole, + type RoleCapabilities, + type RoleCapabilitiesPatch, + type RoleDefinition, + type RoleVisibility, + type WorkspaceRoles +} from '$lib/gen' + +/** Not a role: the `is_admin` flag, offered next to the roles wherever a member's access is picked. */ +export const ADMIN_ROLE = 'admin' + +export const DEVELOPER_ROLE = 'developer' +export const OPERATOR_ROLE = 'operator' + +export const VISIBILITY_KEYS: { key: keyof RoleVisibility; label: string }[] = [ + { key: 'runs', label: 'Runs' }, + { key: 'schedules', label: 'Schedules' }, + { key: 'resources', label: 'Resources' }, + { key: 'variables', label: 'Variables' }, + { key: 'assets', label: 'Assets' }, + { key: 'triggers', label: 'Triggers' }, + { key: 'audit_logs', label: 'Audit logs' }, + { key: 'groups', label: 'Groups' }, + { key: 'folders', label: 'Folders' }, + { key: 'workers', label: 'Workers' } +] + +const ALL_VISIBLE: RoleVisibility = { + runs: true, + schedules: true, + resources: true, + variables: true, + assets: true, + triggers: true, + audit_logs: true, + groups: true, + folders: true, + workers: true +} + +// Mirror `RoleCapabilities::FULL` and `RoleCapabilities::OPERATOR` in `windmill_common::roles`. +export const FULL_CAPABILITIES: RoleCapabilities = { + code: true, + flows: 'full', + apps: 'full', + manage_schedules: true, + manage_triggers: true, + visibility: ALL_VISIBLE +} + +const OPERATOR_CAPABILITIES: RoleCapabilities = { + code: false, + flows: 'none', + apps: 'none', + manage_schedules: true, + manage_triggers: true, + visibility: ALL_VISIBLE +} + +export const DEFAULT_INSTANCE_ROLES: RoleDefinition[] = [ + { name: DEVELOPER_ROLE, label: 'Developer', capabilities: {} }, + { name: OPERATOR_ROLE, label: 'Operator', capabilities: {} } +] + +/** What a role named `name` holds before any configuration: `RoleCapabilities::base_for`. */ +export function baseCapabilitiesFor(name: string): RoleCapabilities { + return applyCapabilitiesPatch( + name === DEVELOPER_ROLE ? FULL_CAPABILITIES : OPERATOR_CAPABILITIES, + undefined + ) +} + +export function applyCapabilitiesPatch( + base: RoleCapabilities, + patch: RoleCapabilitiesPatch | undefined +): RoleCapabilities { + // Spread rather than `structuredClone`: `base` may be a Svelte state proxy, which cannot be cloned. + const out: RoleCapabilities = { ...base, visibility: { ...base.visibility } } + if (!patch) return out + // `!= null`: a stored `null` deserializes to `None` server-side, so it is unset like `undefined`. + if (patch.code != null) out.code = patch.code + if (patch.flows != null) out.flows = patch.flows + if (patch.apps != null) out.apps = patch.apps + if (patch.manage_schedules != null) out.manage_schedules = patch.manage_schedules + if (patch.manage_triggers != null) out.manage_triggers = patch.manage_triggers + for (const { key } of VISIBILITY_KEYS) { + const v = patch.visibility?.[key] + if (v != null) out.visibility[key] = v + } + return out +} + +/** The error the server raises for these capabilities, if any (`RoleCapabilities::validate`). */ +export function capabilitiesError(caps: RoleCapabilities): string | undefined { + if (!caps.code && (caps.flows === 'full' || caps.apps === 'full')) { + return 'Full flow or app authoring requires the code capability' + } + return undefined +} + +/** `validate_role_name` in `windmill_common::roles`. */ +export function roleNameError(name: string): string | undefined { + if (name === ADMIN_ROLE) return 'The name admin is reserved' + if (!/^[a-z0-9_-]{1,50}$/.test(name)) { + return 'Use 1 to 50 lowercase letters, digits, underscores or hyphens' + } + return undefined +} + +/** A patch with no key set, so it is dropped rather than stored as `{}`. */ +export function isEmptyPatch(patch: RoleCapabilitiesPatch | undefined): boolean { + if (!patch) return true + const { visibility, ...rest } = patch + return ( + Object.values(rest).every((v) => v == null) && + Object.values(visibility ?? {}).every((v) => v == null) + ) +} + +/** + * What `parse_instance_roles` would refuse in a `workspace_roles` value, keyed `.name`, + * `.capabilities` or `list`. An unset value is the defaults and always valid. + */ +export function workspaceRolesErrors(value: unknown): Record { + if (value == undefined) return {} + if (!Array.isArray(value)) return { list: 'Expected a list of roles' } + if (value.length === 0) return { list: 'Keep at least one role' } + const errors: Record = {} + const seen = new Set() + value.forEach((r: RoleDefinition, i) => { + const nameError = roleNameError(r?.name ?? '') + if (nameError) errors[`${i}.name`] = nameError + else if (seen.has(r.name)) errors[`${i}.name`] = 'Another role has this name' + seen.add(r?.name) + const capsError = capabilitiesError( + applyCapabilitiesPatch(baseCapabilitiesFor(r?.name ?? ''), r?.capabilities) + ) + if (capsError) errors[`${i}.capabilities`] = capsError + }) + return errors +} + +/** Whether `roles` is the default list, which is stored as no value at all. */ +export function isDefaultInstanceRoles(roles: RoleDefinition[]): boolean { + return ( + roles.length === DEFAULT_INSTANCE_ROLES.length && + roles.every( + (r, i) => + r.name === DEFAULT_INSTANCE_ROLES[i].name && + (r.label ?? '') === DEFAULT_INSTANCE_ROLES[i].label && + isEmptyPatch(r.capabilities) + ) + ) +} + +export function authorLevelLabel(level: AuthorLevel): string { + return level === 'none' ? 'None' : level === 'compose' ? 'Compose' : 'Full' +} + +export async function loadWorkspaceRoles(workspace: string): Promise { + return await WorkspaceService.listWorkspaceRoles({ workspace }) +} + +/** + * The role a new member gets when none is picked: `developer` while the workspace defines it, + * the first role otherwise, as the server picks one. + */ +export function defaultRoleName(roles: EffectiveRole[] | undefined): string { + if (!roles?.length || roles.some((r) => r.name === DEVELOPER_ROLE)) return DEVELOPER_ROLE + return roles[0].name +} + +/** `value` while the workspace still defines it (or it is Admin), the default role otherwise. */ +export function validRoleOr(roles: EffectiveRole[] | undefined, value: string | undefined): string { + if (value === ADMIN_ROLE || (value && (!roles || roles.some((r) => r.name === value)))) { + return value + } + return defaultRoleName(roles) +} + +/** Role picker items: the workspace's roles in their order, then Admin. */ +export function roleItems( + roles: EffectiveRole[] | undefined, + opts: { admin?: boolean } = {} +): { label: string; value: string }[] { + const items = (roles ?? []).map((r) => ({ label: r.label, value: r.name })) + if (opts.admin ?? true) items.push({ label: 'Admin', value: ADMIN_ROLE }) + return items +} + +export function roleLabel(roles: EffectiveRole[] | undefined, name: string | undefined): string { + if (!name) return '' + if (name === ADMIN_ROLE) return 'Admin' + return roles?.find((r) => r.name === name)?.label ?? name +} + +/** The picker value of a member, invite or mapping: `admin` for an admin, its role otherwise. */ +export function pickerValue(m: { is_admin?: boolean; role?: string; operator?: boolean }): string { + if (m.is_admin) return ADMIN_ROLE + return m.role ?? (m.operator ? OPERATOR_ROLE : DEVELOPER_ROLE) +} + +/** + * The fields a picked value is sent as. An admin keeps a role underneath the flag, `developer` + * being what the server gives one when none is named; `operator` is set for servers that predate + * roles. + */ +export function roleRequestFields(value: string): { + is_admin: boolean + operator: boolean + role?: string +} { + if (value === ADMIN_ROLE) return { is_admin: true, operator: false } + return { is_admin: false, operator: value === OPERATOR_ROLE, role: value } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 3084d7dfc2..fd4b828be3 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -54,6 +54,7 @@ nonMemberWorkspaces, setNonMemberWorkspaces, clearNonMemberWorkspaces, + roleCapabilities, type UserWorkspace } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' @@ -93,7 +94,6 @@ import { overlayStack } from '$lib/components/common/overlayHost.svelte' import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' import { BAR_HEIGHT_PX, pageHeader } from '$lib/components/pageHeaderRegistry.svelte' - import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' import type GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import MenuLink from '$lib/components/sidebar/MenuLink.svelte' @@ -166,7 +166,7 @@ let collapsedState = $state(collapsePref.val) // Operators never get the icon-only rail: their docked sidebar is always expanded (a sidebar // they want out of the way is what detaching is for). - let isCollapsed = $derived(collapsedState && !$userStore?.operator) + let isCollapsed = $derived(collapsedState && $roleCapabilities.code) // Resizable desktop rail, sized in REM so it scales with the root font-size the // same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px on screens @@ -247,7 +247,7 @@ // instead: dragging the sidebar off the left edge puts it behind the handle. // Detaching is not a resize, so the width the drag started from is what gets // persisted — re-attaching restores the sidebar at the size it had. - if (collapse && $userStore?.operator) { + if (collapse && !$roleCapabilities.code) { sidebarWidth = widthAtStart stop() setDetached(true) @@ -306,7 +306,7 @@ // (SessionModeSwitch) just navigates in and out of that route. // An operator without the opt-in (`gate.ts`) is refused by /sessions, so their sidebar is not // handed over to a session list the page behind it declines, and they keep the docked chat. - const sessionsAllowed = $derived(sessionsAllowedFor($userStore?.operator)) + const sessionsAllowed = $derived(sessionsAllowedFor(!$roleCapabilities.code)) let sessionMode = $derived(page.url.pathname.startsWith('/sessions') && sessionsAllowed) // Session mode points the bottom settings entry at the open session's own // workspace. An unsent draft hasn't committed one yet, so it falls back to @@ -1146,11 +1146,7 @@ outside this layout, and their shortcut must reopen the URL that is their creden label="Runs" href="/runs" icon={Play} - disabled={!sidebarPageAllowed( - $userStore?.operator, - $userWorkspaces?.find((w) => w.id === $workspaceStore), - 'runs' - )} + disabled={!$roleCapabilities.visibility.runs} isCollapsed={collapsed} aiId="sidebar-menu-link-runs" aiDescription="Button to navigate to runs" @@ -1182,7 +1178,7 @@ outside this layout, and their shortcut must reopen the URL that is their creden with a sidebar they cannot return. --> {#snippet sidebarToggle()} {@const hidden = navDetached.val} - {#if hidden || $userStore?.operator} + {#if hidden || !$roleCapabilities.code} - {#if !($userStore?.operator || (!$userStore?.is_admin && !$superadmin))} + {#if $roleCapabilities.code && ($userStore?.is_admin || $superadmin)}