Files
windmill/frontend/src/lib/components/DeployWorkspace.svelte
T
hugocasaandClaude Opus 5.5 0748a2fb51 refactor: agents run as the caller, with a note on what a shared one needs
Drops the agent run-as identity: its storage in the agent value, the backfill, the resolution
on every write, and its handling in export, the CLI, draft and cross-workspace deploys, forks
and the workspace compare. The run endpoint runs as the caller, so an operator or reader runs an
agent with their own access. The editor tells the author of a folder agent that anyone running
it needs access to its AI resource and to what its tools use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 19:32:16 +02:00

729 lines
24 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, untrack } from 'svelte'
import {
agentResourceDependencies,
aiAgentModuleDependencies,
collectTransformRefs
} from './deployDependencies'
import { base } from '$lib/base'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import {
AppService,
FlowService,
ResourceService,
ScheduleService,
UserService,
WorkspaceService,
type RemoteDeployStatus
} from '$lib/gen'
import { remoteDeployLabel, remoteDeployWorkspace } from '$lib/remoteDeploy'
import RemoteDeployConnect from './RemoteDeployConnect.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import TextInput from './text_input/TextInput.svelte'
import { getAllModules } from './flows/flowExplorer'
import Button from './common/button/Button.svelte'
import Tooltip from './Tooltip.svelte'
import Alert from './common/alert/Alert.svelte'
import { DiffIcon, FileJson, Loader2 } from 'lucide-svelte'
import Badge from './common/badge/Badge.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import {
getTriggerDependency,
type AdditionalInformation,
type Kind
} from '$lib/utils_deployable'
import {
checkItemExists,
deployItem,
getItemValue,
getOnBehalfOf
} from '$lib/utils_workspace_deploy'
import type { App } from './apps/types'
import { getAllGridItems } from './apps/editor/appUtils'
import { isRunnableByPath } from './apps/inputType'
import type { Runnable } from './raw_apps/utils'
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import OnBehalfOfSelector, {
needsOnBehalfOfSelection,
type OnBehalfOfChoice,
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
const dispatch = createEventDispatcher()
interface Props {
kind: Kind
initialPath?: string
additionalInformation?: AdditionalInformation | undefined
workspaceToDeployTo?: string | undefined
hideButton?: boolean
canDeployToWorkspace?: boolean
}
let {
kind,
initialPath = '',
additionalInformation = undefined,
workspaceToDeployTo = $bindable(undefined),
hideButton = false,
canDeployToWorkspace = $bindable(true)
}: Props = $props()
let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined =
$state(undefined)
/** What the target answered when it refused, for a destination whose credential can go stale. */
let targetError: string | undefined = $state(undefined)
// The two destinations a workspace can have: its parent on this instance, and a workspace on
// another instance. Both are set by an admin, so a workspace may have either, both or neither.
type Destination = 'parent' | 'remote'
let parentWorkspace: string | undefined = $state(undefined)
let remoteStatus = $state<RemoteDeployStatus | undefined>(undefined)
let destination: Destination | undefined = $state(undefined)
let remoteTarget = $derived(remoteStatus?.target)
let isRemote = $derived(destination === 'remote')
type Dependency = { kind: Kind; path: string; include: boolean }
let dependencies: Dependency[] | undefined = $state<Dependency[] | undefined>(undefined)
let allAlreadyExists: { [key: string]: boolean } = $state({})
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let notSet: boolean | undefined = $state(undefined)
let isFlow: boolean | undefined = $state(undefined)
// On-behalf-of tracking for flows and scripts
// Source workspace on_behalf_of emails (keyed by kind:path)
let sourceOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
// Target workspace on_behalf_of emails (keyed by kind:path)
let targetOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
let canPreserveOnBehalfOf = $state(false)
// Check if an item needs on_behalf_of selection
function itemNeedsOnBehalfOfSelection(statusPath: string, kind: string): boolean {
return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath])
}
/**
* Get the on_behalf_of value for deployment based on user's choice.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
*/
function getOnBehalfOfForDeploy(statusPath: string, kind: Kind): string | undefined {
const choice = onBehalfOfChoice[statusPath]
if (choice === 'target') return targetOnBehalfOfInfo[statusPath]
if (choice === 'custom') {
const details = customOnBehalfOf[statusPath]
return kind === 'trigger' ? details?.permissionedAs : details?.email
}
// 'me' or undefined = don't pass, backend will use deploying user's identity
return undefined
}
/**
* Authorization half of the on_behalf_of pair for flows/scripts. Only a custom pick
* names one; for every other choice the backend derives it from the email, so
* returning undefined clears the source item's value rather than keeping it.
*/
function getOnBehalfOfPermissionedAsForDeploy(
statusPath: string,
kind: Kind
): string | undefined {
if (kind === 'trigger' || onBehalfOfChoice[statusPath] !== 'custom') return undefined
return customOnBehalfOf[statusPath]?.permissionedAs
}
async function reload(path: string) {
const target = workspaceToDeployTo
// Everything below is about this one target; switching destination starts its own pass, and
// the one it replaced must not write its answers into the new one's state.
const stillCurrent = () => target === workspaceToDeployTo
canSeeTarget = undefined
targetError = undefined
dependencies = undefined
allAlreadyExists = {}
deploymentStatus = {}
targetOnBehalfOfInfo = {}
onBehalfOfChoice = {}
customOnBehalfOf = {}
try {
// Being superadmin here says nothing about the other instance, so a remote target is
// always asked.
let canPreserve = true
if (!$superadmin || isRemote) {
const targetUser = await UserService.whoami({ workspace: target! })
canPreserve =
targetUser.is_admin ||
targetUser.is_super_admin ||
targetUser.groups?.includes('wm_deployers') ||
false
}
if (!stillCurrent()) return
canPreserveOnBehalfOf = canPreserve
canSeeTarget = 'yes'
} catch (e: any) {
if (!stillCurrent()) return
canSeeTarget = 'cant-deploy-to-workspace'
targetError = e?.body ?? e?.message
canPreserveOnBehalfOf = false
return
}
let allDeps
try {
allDeps = await getDependencies(kind, path)
} catch {
if (!stillCurrent()) return
canSeeTarget = 'cant-see-all-deps'
return
}
let sortedSet: { kind: Kind; path: string }[] = []
allDeps.forEach((x) => {
if (!sortedSet.find((y) => y.kind == x.kind && y.path == x.path)) {
sortedSet.push(x)
}
})
for (const dep of sortedSet) {
const exists = await checkAlreadyExists(dep.kind, dep.path)
if (!stillCurrent()) return
allAlreadyExists[computeStatusPath(dep.kind, dep.path)] = exists
}
dependencies = sortedSet.map((x) => ({
...x,
include:
x.kind != 'variable' &&
x.kind != 'resource' &&
x.kind != 'resource_type' &&
(x.kind != 'folder' || !allAlreadyExists[computeStatusPath(x.kind, x.path)])
}))
// Fetch on_behalf_of_email for flows, scripts, apps, and triggers from both workspaces
for (const dep of sortedSet.filter((d) =>
['flow', 'script', 'app', 'trigger'].includes(d.kind)
)) {
const key = computeStatusPath(dep.kind, dep.path)
let source: string | undefined
let targetValue: string | undefined
try {
source = await getOnBehalfOf(dep.kind, dep.path, $workspaceStore!, additionalInformation)
} catch {}
try {
targetValue = await getOnBehalfOf(dep.kind, dep.path, target!, additionalInformation)
} catch {}
if (!stillCurrent()) return
sourceOnBehalfOfInfo[key] = source
targetOnBehalfOfInfo[key] = targetValue
}
}
async function getDependencies(
kind: Kind,
path: string
): Promise<{ kind: Kind; path: string }[]> {
async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> {
if (kind == 'schedule') {
const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path })
if (schedule.script_path && schedule.script_path != '') {
if (schedule.script_path) {
return [{ kind: 'script', path: schedule.script_path }]
} else {
return [{ kind: 'flow', path: schedule.script_path }]
}
} else {
return []
}
} else if (kind == 'flow') {
const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path })
return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => {
let result: { kind: Kind; path: string }[] = []
if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') {
result.push(...collectTransformRefs(x.value.input_transforms))
}
if (x.value.type == 'script') {
if (x.value.path && !x.value.path.startsWith('hub/')) {
result.push({ kind: 'script', path: x.value.path })
}
} else if (x.value.type == 'flow') {
if (x.value.path) {
result.push({ kind: 'flow', path: x.value.path })
}
} else if (x.value.type == 'aiagent') {
result.push(...aiAgentModuleDependencies(x.value))
}
return result
})
} else if (kind == 'app') {
const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path })
let result: { kind: Kind; path: string }[] = []
if (app.raw_app) {
const rawAppValue = app.value as { runnables?: Record<string, Runnable> }
for (const runnable of Object.values(rawAppValue.runnables ?? {})) {
if (isRunnableByPath(runnable)) {
if (runnable.runType == 'script') {
result.push({ kind: 'script', path: runnable.path })
} else if (runnable.runType == 'flow') {
result.push({ kind: 'flow', path: runnable.path })
}
}
}
} else {
let appValue = app.value as App
getAllGridItems(appValue).forEach((gridItem) => {
const ci = gridItem.data.componentInput
if (ci?.type == 'runnable' && isRunnableByPath(ci.runnable)) {
if (ci.runnable.runType == 'script') {
result.push({ kind: 'script', path: ci.runnable.path })
} else if (ci.runnable.runType == 'flow') {
result.push({ kind: 'flow', path: ci.runnable.path })
}
}
})
}
return result
} else if (kind == 'resource') {
const res = await ResourceService.getResource({ workspace: $workspaceStore!, path })
function recObj(obj: any): { kind: Kind; path: string }[] {
if (typeof obj == 'string') {
if (obj.startsWith('$var:')) {
return [{ kind: 'variable', path: obj.substring(5) }]
} else if (obj.startsWith('$jsonvar:')) {
return [{ kind: 'variable', path: obj.substring(9) }]
} else if (obj.startsWith('$res:')) {
return [{ kind: 'resource', path: obj.substring(5) }]
}
return []
} else if (typeof obj == 'object' && obj != null) {
return Object.values(obj).flatMap((x) => recObj(x))
} else {
return []
}
}
return [
...recObj(res.value),
...(res.resource_type == 'ai_agent' ? agentResourceDependencies(res.value) : []),
{ kind: 'resource_type' as Kind, path: res.resource_type }
]
} else if (kind == 'trigger') {
if (additionalInformation?.triggers) {
return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!)
}
throw new Error('Missing trigger information')
} else if (kind == 'script') {
const imports = await WorkspaceService.getImports({
workspace: $workspaceStore!,
importerPath: path
})
return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath }))
}
return []
}
let toProcess = [{ kind, path }]
let processedSet = new Set<string>()
let processed: { kind: Kind; path: string }[] = []
while (toProcess.length > 0) {
const { kind, path } = toProcess.pop()!
const key = `${kind}:${path}`
if (processedSet.has(key)) {
continue
}
processedSet.add(key)
toProcess.push(...(await rec(kind, path)))
processed.push({ kind, path })
}
let folders: string[] = []
for (const p of processed) {
let split = p.path.split('/')
if (split.length > 2 && split[0] == 'f' && !folders.includes(split[1])) {
folders.push(split[1])
processed.push({ kind: 'folder', path: split[1] })
}
}
return processed
}
async function checkAlreadyExists(kind: Kind, path: string): Promise<boolean> {
return checkItemExists(kind, path, workspaceToDeployTo!, additionalInformation)
}
let deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
async function deploy(kind: Kind, path: string) {
const statusPath = computeStatusPath(kind, path)
const target = workspaceToDeployTo
deploymentStatus[statusPath] = { status: 'loading' }
const result = await deployItem({
kind,
path,
workspaceFrom: $workspaceStore!,
workspaceTo: target!,
additionalInformation,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind),
onBehalfOfPrincipal: getOnBehalfOfPermissionedAsForDeploy(statusPath, kind),
targetBaseUrl: isRemote ? remoteTarget?.base_url : undefined
})
// The statuses on screen are the current destination's; this deploy went to `target`.
if (target !== workspaceToDeployTo) return
if (result.success) {
allAlreadyExists[statusPath] = true
deploymentStatus[statusPath] = { status: 'deployed' }
} else {
deploymentStatus[statusPath] = { status: 'failed', error: result.error }
}
}
export function deployAll() {
dependencies?.slice().forEach(async ({ kind, path, include }) => {
if (include) {
await deploy(kind, path)
}
})
dispatch('update', initialPath)
}
function computeStatusPath(kind: Kind, path: string) {
return `${kind}:${path}`
}
async function showDiff(kind: Kind, path: string) {
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceToDeployTo!, additionalInformation),
getItemValue(kind, path, $workspaceStore!, additionalInformation)
])
diffDrawer?.setDiff({
mode: 'simple',
original: values?.[0] as any,
current: values?.[1] as any,
title: 'Staging/prod <> Dev'
})
}
async function loadDestinations() {
const workspace = $workspaceStore!
const [deployTo, remote] = await Promise.all([
WorkspaceService.getDeployTo({ workspace }),
WorkspaceService.getRemoteDeployTarget({ workspace }).catch(() => undefined)
])
if (workspace !== $workspaceStore) return
parentWorkspace = deployTo.deploy_to
remoteStatus = remote
// Keep the user's pick across a reload, but only while this workspace still has it: the
// drawer stays mounted when the workspace is switched.
const available = {
parent: parentWorkspace != undefined,
remote: remote?.target != undefined
}
if (!destination || !available[destination]) {
destination = available.parent ? 'parent' : available.remote ? 'remote' : undefined
}
notSet = destination == undefined
}
$effect(() => {
$workspaceStore && untrack(() => loadDestinations())
})
// Until the caller has a token for the remote instance there is nothing to deploy against, so
// the target stays unset and the drawer's Deploy buttons stay disabled.
let destinationWorkspace = $derived(
destination === 'parent'
? parentWorkspace
: isRemote && remoteStatus?.connection
? remoteDeployWorkspace($workspaceStore!, remoteStatus.connection)
: undefined
)
let destinationLabel = $derived(
isRemote && remoteTarget ? remoteDeployLabel(remoteTarget) : (parentWorkspace ?? '')
)
$effect(() => {
workspaceToDeployTo = destinationWorkspace
})
$effect(() => {
workspaceToDeployTo && initialPath && untrack(() => reload(initialPath))
})
// Transform dependencies to deployable item format for the shared layout
let deployableItems = $derived(
(dependencies ?? []).map((dep) => ({
key: computeStatusPath(dep.kind, dep.path),
path: dep.path,
kind: dep.kind,
include: dep.include,
triggerKind: dep.kind === 'trigger' ? additionalInformation?.triggers?.kind : undefined
}))
)
let selectedItems = $derived<string[]>(
(dependencies ?? [])
.filter((dep) => dep.include)
.map((dep) => computeStatusPath(dep.kind, dep.path))
)
let allSelected = $derived(
dependencies != null && dependencies.length > 0 && dependencies.every((dep) => dep.include)
)
// Check if all required on_behalf_of selections are made
let hasUnselectedOnBehalfOf = $derived(
selectedItems.some((statusPath) => {
const dep = dependencies?.find((d) => computeStatusPath(d.kind, d.path) === statusPath)
if (!dep) return false
return (
itemNeedsOnBehalfOfSelection(statusPath, dep.kind) &&
onBehalfOfChoice[statusPath] === undefined
)
})
)
function toggleItem(item: { key: string }) {
if (dependencies) {
const idx = dependencies.findIndex(
(dep) => computeStatusPath(dep.kind, dep.path) === item.key
)
if (idx !== -1) {
dependencies[idx].include = !dependencies[idx].include
}
}
}
function selectAll() {
if (dependencies) {
dependencies = dependencies.map((dep) => ({ ...dep, include: true }))
}
}
function deselectAll() {
if (dependencies) {
dependencies = dependencies.map((dep) => ({ ...dep, include: false }))
}
}
</script>
<div class="mt-6"></div>
{#if !$enterpriseLicense}
<Alert type="warning" title="Enterprise license required"
>Deploy to staging/prod from the web UI is only available with an enterprise license</Alert
>
{:else if notSet == true}
<Alert type="error" title="Deploy destination not set up"
>As an admin, go to Settings {'->'} Workspace {'->'} Dev workspace, to pair this workspace with a
prod workspace on this instance or point it at a workspace on another instance</Alert
>
{:else}
<Alert type="info" title="Shareable page"
>Share this <a href="{base}/deploy/{kind}/{initialPath}">link</a> to have another properly permissioned
user do the deployment</Alert
>
<h3 class="mb-2 mt-8"
>Destination&nbsp; <Tooltip
>Where this workspace deploys is set in the workspace settings</Tooltip
></h3
>
{#if parentWorkspace && remoteTarget}
<ToggleButtonGroup
selected={destination}
onSelected={(v) => (destination = v as Destination)}
noWFull
>
{#snippet children({ item })}
<ToggleButton
value="parent"
label={parentWorkspace ?? ''}
tooltip="Workspace this one deploys into on this instance"
{item}
/>
<ToggleButton
value="remote"
label={remoteDeployLabel(remoteTarget!)}
tooltip="Workspace on another instance"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{:else}
<TextInput value={destinationLabel} inputProps={{ disabled: true }} class="max-w-xs" />
{/if}
{#if isRemote && remoteTarget}
<div class="mt-3">
<RemoteDeployConnect
workspace={$workspaceStore!}
target={remoteTarget}
connection={remoteStatus?.connection}
returnTo={kind === 'trigger'
? undefined
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($workspaceStore!)}`}
onChange={loadDestinations}
/>
</div>
{/if}
{#if workspaceToDeployTo}
<ParentWorkspaceProtectionAlert
parentWorkspaceId={workspaceToDeployTo}
displayName={destinationLabel}
onUpdateCanDeploy={(canDeploy) => {
canDeployToWorkspace = canDeploy
}}
/>
{/if}
{#if workspaceToDeployTo == undefined}
<!-- A remote destination nobody has connected to yet: the connect form above is the next step. -->
{:else if canSeeTarget == undefined}
<div class="mt-6"></div>
<Loader2 class="animate-spin" />
{:else if canSeeTarget == 'yes'}
<h3 class="mb-6 mt-16">All related deployable items</h3>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<WorkspaceDeployLayout
items={deployableItems}
{selectedItems}
{deploymentStatus}
selectablePredicate={() => true}
{allSelected}
onToggleItem={toggleItem}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage="No deployable items found"
>
{#snippet itemActions(item)}
{@const statusPath = item.key}
{@const exists = allAlreadyExists[statusPath]}
{@const status = deploymentStatus[statusPath]}
{@const targetValue = targetOnBehalfOfInfo[statusPath]}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(statusPath, item.kind)}
<OnBehalfOfSelector
targetWorkspace={workspaceToDeployTo!}
{targetValue}
selected={onBehalfOfChoice[statusPath]}
onSelect={(choice, details) => {
onBehalfOfChoice[statusPath] = choice
if (details) customOnBehalfOf[statusPath] = details
}}
kind={item.kind}
canPreserve={canPreserveOnBehalfOf}
customValue={customOnBehalfOf[statusPath]?.permissionedAs}
/>
{/if}
{#if item.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
{#if exists === false}
{#if item.include}
<Badge
>New <Tooltip
>This {item.kind} doesn't exist yet on the target and will be created by the deployment</Tooltip
></Badge
>
{:else}
<Badge color="red">
Missing
<Tooltip
>{#if item.kind == 'resource_type'}
Resource types are not re-deployed by default. We strongly recommend to add shared
resource types in 'admin' workspace, which will have them be shared to every
workspace.
{:else}
This {item.kind} doesn't exist and is not included in the deployment. Variables and
Resources are considered to be workspace specific and are never included by default.
{/if}</Tooltip
>
</Badge>
{/if}
{:else if exists === true && !status}
<Button
size="xs"
variant="subtle"
onclick={() => {
showDiff(item.kind, item.path)
isFlow = item.kind === 'flow'
}}
>
<DiffIcon class="w-3 h-3" />
Show diff
</Button>
{/if}
{#if !status}
<Button
color="light"
size="xs"
disabled={!canDeployToWorkspace ||
(itemNeedsOnBehalfOfSelection(statusPath, item.kind) &&
onBehalfOfChoice[statusPath] === undefined)}
onclick={() => deploy(item.kind, item.path)}>Deploy</Button
>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex flex-col items-end gap-2">
{#if !hideButton}
<Button on:click={deployAll} disabled={!canDeployToWorkspace || hasUnselectedOnBehalfOf}
>Deploy all toggled</Button
>
{/if}
{#if hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
{#if kind === 'trigger'}
You must set the "permissioned as" user for all triggers before deploying
<Tooltip class="text-yellow-600">
The "permissioned as" field defines which user's permissions will be applied when
the trigger fires. Make sure this is set appropriately before deploying.
</Tooltip>
{:else}
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be applied
during execution. Make sure this is set to an appropriate user before deploying.
</Tooltip>
{/if}
</span>
{/if}
</div>
{/snippet}
</WorkspaceDeployLayout>
{:else if canSeeTarget == 'cant-see-all-deps'}
<div class="my-2"></div>
<Alert type="error" title="User doesn't have visibility over all dependencies"
>You do not have visibility over some of the dependencies of this item. Ask a permissioned
user to deploy this item using the shareable link or get the proper permissions on the
dependencies</Alert
>
{:else if isRemote}
<div class="my-2"></div>
<Alert type="error" title="Could not deploy to {destinationLabel}"
><p>{targetError ?? 'The remote instance did not accept the request'}</p>
<p class="mt-1"
>Disconnect above and connect again with a valid token for that instance, or ask someone
permissioned there to deploy this item using the shareable link</p
></Alert
>
{:else}
<div class="my-2"></div>
<Alert type="error" title="User not allowed to deploy to this workspace"
>Ask a permissioned user to deploy this item using the shareable link or get the proper
permissions on the target workspace</Alert
>
{/if}
{/if}