Files
windmill/frontend/src/lib/utils_workspace_deploy.ts
T
Ruben FiszelandClaude Opus 5 c90d1d95c2 refactor: make the app policy's principal the authority for its identity (#10440)
* refactor: make the app policy's principal the authority for its identity

* fix: align the app backfill with the sibling migration and audit the uncached address

* chore: refresh the sqlx cache after rebasing onto the merged base

* fix: resolve the app execution address uncached, it decides the job's authorization

* chore: cache the EE queries at the ref this branch pins

* chore: cache the EE queries at the ref this branch pins

* fix: derive the app draft's on-behalf-of address on read

* chore: cache the query the draft derivation test added

* fix: derive the app identity on the draft-table and version reads too

* docs: state the draft resolver's authorization contract

* fix: resolve a draft's principal against workspace membership only

* chore: cache the membership lookup the draft resolver added

* fix: drop an unresolvable draft's address instead of leaving it stale

* perf: evict the address cache on change so app dispatch can read it

* fix: evict on superadmin role changes, not only address changes

* refactor: make the app policy's address optional instead of derived on read

* fix: follow an external superadmin's rename into the apps that name them

* docs: state the removal gate once, and correctly

* refactor: drop the app-policy version constant that gated nothing

* docs: drop the last reference to the removed constant

* perf: read the address cache everywhere now that eviction reaches every replica

* fix: keep persisted addresses off the cache the poller evicts asynchronously

* docs: state where the cached address is accepted and where it is not

* docs: keep the cache rule in one place and drop the stale premise

* docs: sort the two lookups by how long a wrong answer lives

* fix: resolve the schedule address uncached where it is written to the row

* docs: name the release this actually ships in

* perf: evict a superadmin's key per workspace instead of the whole cache

* fix: evict every alias a superadmin principal can be spelled as

* docs: describe the trigger as it is

* docs: cover the round-tripped read in the cache rule

* docs: record why a stale dispatch address cannot escalate

* fix: validate a dispatch address against the principal's live binding

* fix: carry the validated address through to the job row and token

* fix: record the validated address on the job row, not the one handed in

* test: run the substep tag check as the non-superadmin it means to test

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: rewrite a stored app address that disagrees with its principal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the accepted staleness window of the cached dispatch address

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: record the validated address on the job's audit row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the accepted rename race of pre-transaction identity resolution

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: separate the app's stored address from the derived one in the resolver doc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: describe the job identity fast path the push comments skipped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: backfill a legacy group-prefixed username as the group it names

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: resolve a schedule edit's identity before opening its transaction

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: never resolve a disabled member to a same-named superadmin

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: state what the email-change notify buys, and rewrap two comment lines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: keep a group's runnables when offboarding a legacy group-prefixed member

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* fix: read the app author from the stored address, as execution does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the rename race's full consequence as a known, accepted limitation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

* docs: record the keep-target group address case as a known, accepted limitation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY4bBCR1q2c5XB8s2r7Ysc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:10:15 +02:00

947 lines
36 KiB
TypeScript

import {
AppService,
AzureTriggerService,
EmailTriggerService,
FlowService,
FolderService,
GcpTriggerService,
HttpTriggerService,
KafkaTriggerService,
MqttTriggerService,
NatsTriggerService,
PostgresTriggerService,
ResourceService,
ScheduleService,
ScriptService,
SqsTriggerService,
GroupService,
UserService,
VariableService,
WebsocketTriggerService,
WorkspaceService,
type User
} from '$lib/gen'
import type { UserDraftItemKind } from '$lib/gen'
import {
fetchProtectionRulesForWorkspace,
canUserBypassRuleKindInRulesets
} from '$lib/workspaceProtectionRules.svelte'
import {
existsTrigger,
getTriggersDeployData,
getTriggerPermissionedAs,
getTriggerValue,
stripOperationalState,
type AdditionalInformation,
type Kind
} from '$lib/utils_deployable'
import {
deployItem as sharedDeployItem,
deleteItemInWorkspace as sharedDeleteItem,
checkItemExists as sharedCheckItemExists,
getOnBehalfOf as sharedGetOnBehalfOf,
getItemValue as sharedGetItemValue,
type DeployProvider,
type DeployKind,
type DeployResult,
type TriggerDeployKind
} from 'windmill-utils-internal'
export type { DeployResult, DeployKind, TriggerDeployKind }
// ---------------------------------------------------------------------------
// Provider adapter — wraps frontend's class-based services
// ---------------------------------------------------------------------------
/**
* Map a shared `TriggerDeployKind` (e.g. `kafka_trigger`) to the per-kind
* service class. Per-kind dispatch lives in the adapter so the shared
* `deployItem` only needs to know "trigger" vs "not trigger".
*/
function triggerServiceFor(kind: TriggerDeployKind) {
switch (kind) {
case 'http_trigger':
return {
exists: HttpTriggerService.existsHttpTrigger,
get: HttpTriggerService.getHttpTrigger,
create: HttpTriggerService.createHttpTrigger,
update: HttpTriggerService.updateHttpTrigger,
delete: HttpTriggerService.deleteHttpTrigger
}
case 'websocket_trigger':
return {
exists: WebsocketTriggerService.existsWebsocketTrigger,
get: WebsocketTriggerService.getWebsocketTrigger,
create: WebsocketTriggerService.createWebsocketTrigger,
update: WebsocketTriggerService.updateWebsocketTrigger,
delete: WebsocketTriggerService.deleteWebsocketTrigger
}
case 'kafka_trigger':
return {
exists: KafkaTriggerService.existsKafkaTrigger,
get: KafkaTriggerService.getKafkaTrigger,
create: KafkaTriggerService.createKafkaTrigger,
update: KafkaTriggerService.updateKafkaTrigger,
delete: KafkaTriggerService.deleteKafkaTrigger
}
case 'nats_trigger':
return {
exists: NatsTriggerService.existsNatsTrigger,
get: NatsTriggerService.getNatsTrigger,
create: NatsTriggerService.createNatsTrigger,
update: NatsTriggerService.updateNatsTrigger,
delete: NatsTriggerService.deleteNatsTrigger
}
case 'postgres_trigger':
return {
exists: PostgresTriggerService.existsPostgresTrigger,
get: PostgresTriggerService.getPostgresTrigger,
create: PostgresTriggerService.createPostgresTrigger,
update: PostgresTriggerService.updatePostgresTrigger,
delete: PostgresTriggerService.deletePostgresTrigger
}
case 'mqtt_trigger':
return {
exists: MqttTriggerService.existsMqttTrigger,
get: MqttTriggerService.getMqttTrigger,
create: MqttTriggerService.createMqttTrigger,
update: MqttTriggerService.updateMqttTrigger,
delete: MqttTriggerService.deleteMqttTrigger
}
case 'sqs_trigger':
return {
exists: SqsTriggerService.existsSqsTrigger,
get: SqsTriggerService.getSqsTrigger,
create: SqsTriggerService.createSqsTrigger,
update: SqsTriggerService.updateSqsTrigger,
delete: SqsTriggerService.deleteSqsTrigger
}
case 'gcp_trigger':
return {
exists: GcpTriggerService.existsGcpTrigger,
get: GcpTriggerService.getGcpTrigger,
create: GcpTriggerService.createGcpTrigger,
update: GcpTriggerService.updateGcpTrigger,
delete: GcpTriggerService.deleteGcpTrigger
}
case 'azure_trigger':
return {
exists: AzureTriggerService.existsAzureTrigger,
get: AzureTriggerService.getAzureTrigger,
create: AzureTriggerService.createAzureTrigger,
update: AzureTriggerService.updateAzureTrigger,
delete: AzureTriggerService.deleteAzureTrigger
}
case 'email_trigger':
return {
exists: EmailTriggerService.existsEmailTrigger,
get: EmailTriggerService.getEmailTrigger,
create: EmailTriggerService.createEmailTrigger,
update: EmailTriggerService.updateEmailTrigger,
delete: EmailTriggerService.deleteEmailTrigger
}
default: {
// Exhaustiveness guard: extending TriggerDeployKind without a case here
// produces a compile error rather than a silent runtime failure.
const _exhaustive: never = kind
throw new Error(`Unhandled trigger kind: ${_exhaustive}`)
}
}
}
/**
* Map the shared `TriggerDeployKind` to the legacy frontend `TriggerKind`
* used by helpers in `utils_deployable.ts`.
*/
function legacyTriggerKind(kind: TriggerDeployKind) {
const map = {
http_trigger: 'routes',
websocket_trigger: 'websockets',
kafka_trigger: 'kafka',
nats_trigger: 'nats',
postgres_trigger: 'postgres',
mqtt_trigger: 'mqtt',
sqs_trigger: 'sqs',
gcp_trigger: 'gcp',
azure_trigger: 'azure',
email_trigger: 'emails'
} as const
return map[kind]
}
/** An identity in both formats the app policy stores it in. */
export type AppIdentity = { email: string; permissionedAs: string }
/**
* Set when a create-only deploy was refused because the target turned out to already have the
* item. Carried on an object rather than matched out of the error text: `deployItem` swallows
* every throw into `{ success: false, error }`, so the flag is the only reliable signal.
*/
export type DeployConflict = { hit: boolean }
/**
* `deployItem` overrides only the email half of the identity, while the body it builds
* spreads the *source* item — which carries the source workspace's permissioned_as, valid
* nowhere else since usernames are per-workspace. The key is therefore always overwritten:
* with the picked user's principal for a custom choice, and cleared otherwise so the
* backend derives the target's own from the email it is given. The shared `deployItem`
* does the same, but this app consumes the published package, so the rewrite has to exist
* on both sides until that version ships.
*
* An app carries both halves inside its `policy` instead, so it needs both stamped here:
* the published package leaves the policy untouched, and a source principal beside a
* target address is rejected as a pair naming two different accounts.
*
* A group kept as the target identity travels as its synthetic `group-*@windmill.dev`
* address, which an admin-created account holding it would win on the backend: known and
* accepted, see `users::permissioned_as_from_email`.
*/
function makeProvider(
onBehalfOfPrincipal?: string,
onBehalfOf?: string,
/**
* Refuse the writes the shared `deployItem` reaches for only when the item already exists in
* the target, turning its silent switch to an update into a failure the caller can act on.
* The three below are exactly its `alreadyExists` branches: a flow and an app are replaced
* outright, and a script is given the target's head as `parent_hash`, which is what makes an
* otherwise identical `createScript` an update.
*/
conflict?: DeployConflict
): DeployProvider {
const withPermissionedAs = <T extends Record<string, any>>(requestBody: T): T => ({
...requestBody,
on_behalf_of: onBehalfOfPrincipal
})
const withPolicyIdentity = <T extends Record<string, any>>(app: T): T =>
app.policy
? {
...app,
policy: {
...app.policy,
on_behalf_of: onBehalfOfPrincipal,
on_behalf_of_email: onBehalfOf
}
}
: app
const refuseUpdate = (): never => {
if (conflict) conflict.hit = true
throw new Error('item already exists in the target workspace')
}
return {
existsFlowByPath: (p) => FlowService.existsFlowByPath(p),
existsScriptByPath: (p) => ScriptService.existsScriptByPath(p),
existsApp: (p) => AppService.existsApp(p),
existsVariable: (p) => VariableService.existsVariable(p),
existsResource: (p) => ResourceService.existsResource(p),
existsResourceType: (p) => ResourceService.existsResourceType(p),
existsFolder: (p) => FolderService.existsFolder(p),
getFlowByPath: (p) => FlowService.getFlowByPath(p),
createFlow: (p) =>
FlowService.createFlow({ ...p, requestBody: withPermissionedAs(p.requestBody) }),
updateFlow: (p) =>
conflict
? refuseUpdate()
: FlowService.updateFlow({ ...p, requestBody: withPermissionedAs(p.requestBody) }),
archiveFlowByPath: (p) => FlowService.archiveFlowByPath(p),
getScriptByPath: (p) => ScriptService.getScriptByPath(p),
createScript: (p) =>
conflict && p.requestBody.parent_hash
? refuseUpdate()
: ScriptService.createScript({ ...p, requestBody: withPermissionedAs(p.requestBody) }),
archiveScriptByPath: (p) => ScriptService.archiveScriptByPath(p),
getAppByPath: (p) => AppService.getAppByPath(p),
createApp: (p) =>
AppService.createApp({ ...p, requestBody: withPolicyIdentity(p.requestBody) }),
updateApp: (p) =>
conflict
? refuseUpdate()
: AppService.updateApp({ ...p, requestBody: withPolicyIdentity(p.requestBody) }),
createAppRaw: (p) =>
AppService.createAppRaw({
...p,
formData: { ...p.formData, app: withPolicyIdentity(p.formData.app) }
}),
updateAppRaw: (p) =>
conflict
? refuseUpdate()
: AppService.updateAppRaw({
...p,
formData: { ...p.formData, app: withPolicyIdentity(p.formData.app) }
}),
getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p),
getRawAppData: (p) => AppService.getRawAppData(p),
deleteApp: (p) => AppService.deleteApp(p),
getVariable: (p) => VariableService.getVariable(p),
createVariable: (p) => VariableService.createVariable(p),
updateVariable: (p) => VariableService.updateVariable(p),
deleteVariable: (p) => VariableService.deleteVariable(p),
getResource: (p) => ResourceService.getResource(p),
createResource: (p) => ResourceService.createResource(p),
updateResource: (p) => ResourceService.updateResource(p),
deleteResource: (p) => ResourceService.deleteResource(p),
getResourceType: (p) => ResourceService.getResourceType(p),
createResourceType: (p) => ResourceService.createResourceType(p),
updateResourceType: (p) => ResourceService.updateResourceType(p),
deleteResourceType: (p) => ResourceService.deleteResourceType(p),
getFolder: (p) => FolderService.getFolder(p),
createFolder: (p) => FolderService.createFolder(p),
updateFolder: (p) => FolderService.updateFolder(p),
deleteFolder: (p) => FolderService.deleteFolder(p),
// Triggers
existsTriggerByKind: (kind, p) => triggerServiceFor(kind).exists(p),
getTriggerForDeploy: async (kind, p) => {
// Reuses the existing per-kind transform map (e.g. GCP wipes
// subscription_id and computes base_endpoint from window.location).
// Operational-state strip is applied by the shared `deployItem`
// after this returns.
const { data } = await getTriggersDeployData(
legacyTriggerKind(kind),
p.path,
p.workspace,
p.onBehalfOf
)
return data
},
createTriggerByKind: (kind, p) => triggerServiceFor(kind).create(p as any),
updateTriggerByKind: (kind, p) => triggerServiceFor(kind).update(p as any),
deleteTriggerByKind: (kind, p) => triggerServiceFor(kind).delete(p),
getTriggerValue: (kind, p) => getTriggerValue(legacyTriggerKind(kind), p.path, p.workspace),
getTriggerPermissionedAs: async (kind, p) => {
const trigger = await triggerServiceFor(kind).get(p)
return (trigger as any)?.permissioned_as
},
// Schedules
existsSchedule: (p) => ScheduleService.existsSchedule(p),
getSchedule: (p) => ScheduleService.getSchedule(p),
createSchedule: (p) => ScheduleService.createSchedule(p),
updateSchedule: (p) => ScheduleService.updateSchedule(p),
deleteSchedule: (p) => ScheduleService.deleteSchedule(p),
// Datatable migrations
listDatatableMigrations: (p) => WorkspaceService.listDatatableMigrations(p),
upsertDatatableMigration: (p) => WorkspaceService.upsertDatatableMigration(p),
deleteDatatableMigration: (p) => WorkspaceService.deleteDatatableMigration(p)
}
}
// ---------------------------------------------------------------------------
// Public API — thin wrappers over the shared dispatch
// ---------------------------------------------------------------------------
export interface DeployItemParams {
kind: Kind
path: string
workspaceFrom: string
workspaceTo: string
/**
* Carries the trigger sub-kind for the legacy generic `kind: 'trigger'` path
* used by `DeployWorkspace.svelte`. Not needed for the new per-kind names
* (`http_trigger`, `kafka_trigger`, …) returned by the fork-merge compare API.
*/
additionalInformation?: AdditionalInformation
/**
* The value to use for on_behalf_of when deploying.
* Format varies by item kind:
* - For flows/scripts/apps: an email address (on_behalf_of_email)
* - For triggers/schedules: permissioned_as format (u/username or g/group)
* If set, preserve_on_behalf_of / preserve_permissioned_as will be true.
* If undefined, the deploying user's identity is used.
*/
onBehalfOf?: string
/**
* Authorization half of `onBehalfOf` (u/username or g/group). Must name the same identity as
* `onBehalfOf`. Set it only when the user picked a specific user; undefined clears the key,
* leaving the backend to derive the target workspace's own principal from `onBehalfOf`. Apps
* additionally need it in the policy, which holds both formats — see `makeProvider`.
*/
onBehalfOfPrincipal?: string
/**
* Fail instead of overwriting when the target turns out to already have the item. The shared
* deploy re-probes and silently switches to an update, so a caller that only means to create —
* one acting on the item being absent — has to say so or it will overwrite whoever got there
* between the two probes. The result then carries `conflict`.
*/
createOnly?: boolean
}
/**
* Deploy an item from one workspace to another. Handles every kind in the shared
* `DeployKind` union plus the legacy generic `'trigger'` from `DeployWorkspace.svelte`,
* which carries its sub-kind in `additionalInformation`.
*/
export async function deployItem(
params: DeployItemParams
): Promise<DeployResult & { conflict?: boolean }> {
const {
kind,
path,
workspaceFrom,
workspaceTo,
additionalInformation,
onBehalfOf,
onBehalfOfPrincipal,
createOnly
} = params
if (kind === 'trigger') {
// Legacy path: `DeployWorkspace.svelte` doesn't know the per-kind trigger
// name when building dependency graphs, so it passes `kind: 'trigger'` and
// the actual sub-kind in `additionalInformation`. Translate to per-kind.
if (!additionalInformation?.triggers) {
return { success: false, error: 'Missing triggers kind' }
}
try {
const alreadyExists = await existsTrigger(
{ workspace: workspaceTo, path },
additionalInformation.triggers.kind
)
const { data, createFn, updateFn } = await getTriggersDeployData(
additionalInformation.triggers.kind,
path,
workspaceFrom,
onBehalfOf
)
if (alreadyExists) {
// Strip operational state so the update doesn't flip the target's
// existing enabled/mode flag — preserved via `is_mode_unspecified()`
// on the backend. Mirrors the shared `stripOperationalStateOnUpdate`
// in the merge-deploy path.
const stripped = stripOperationalState(data)
await updateFn({ path, workspace: workspaceTo, requestBody: stripped } as any)
} else {
// Create — pass source's `mode`/`enabled` through so a new
// trigger lands with the state the source workspace had.
await createFn({ workspace: workspaceTo, requestBody: data } as any)
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.body || e.message || String(e) }
}
}
const conflict: DeployConflict | undefined = createOnly ? { hit: false } : undefined
const result = await sharedDeployItem(
makeProvider(onBehalfOfPrincipal, onBehalfOf, conflict),
kind as DeployKind,
path,
workspaceFrom,
workspaceTo,
onBehalfOf
)
return conflict?.hit ? { ...result, conflict: true } : result
}
/**
* The two sides of a `workspace_diff` row a deploy direction reads:
* `exists_in_source` is the parent (or arbitrary target) side, `exists_in_fork`
* the current workspace. `fork_last_event_*` is what the tally recorded for the
* fork's last write at this path, absent on a row that predates the recording.
*/
type WorkspaceDiffSides = {
ahead: number
behind: number
exists_in_source: boolean
exists_in_fork: boolean
fork_last_event_kind?: 'write' | 'delete' | 'rename_from'
fork_last_event_origin?: 'authored' | 'sync'
}
/** Deploying this row creates the item in the target, which does not have it. */
export function diffCreatesInTarget(diff: WorkspaceDiffSides, mergeIntoParent: boolean): boolean {
return mergeIntoParent ? diff.exists_in_source === false : diff.exists_in_fork === false
}
/**
* Deploying this row removes the item in the target, the only side that has it.
* A removal is always opt-in (never bulk-selected): the row states what deploying
* does, and for the merge direction `diffForkDroppedItem` is what justifies
* offering it at all.
*/
export function diffRemovesInTarget(diff: WorkspaceDiffSides, mergeIntoParent: boolean): boolean {
return mergeIntoParent ? diff.exists_in_fork === false : diff.exists_in_source === false
}
/**
* The fork dropped the item on purpose — someone deleted it, or renamed it away.
* The counters cannot show this (they count writes on a side without saying what
* they were), so only the recorded event does: a sync-origin removal is a git-sync
* revert rather than a fork decision, and an unrecorded one is no evidence at all.
*/
export function diffForkDroppedItem(diff: WorkspaceDiffSides): boolean {
return (
diff.fork_last_event_origin === 'authored' &&
(diff.fork_last_event_kind === 'delete' || diff.fork_last_event_kind === 'rename_from')
)
}
/**
* Rows a deploy in this direction can act on. A merge carries what the fork *has*,
* plus what it can show it dropped; an item the fork merely never received is no
* fork change. The update direction takes a parent-only row whatever the counters
* say. An arbitrary target merges one unconditionally — that one-way sync has no
* tally, so target-only does mean "remove".
*/
export function diffActionableInDirection(
diff: WorkspaceDiffSides,
mergeIntoParent: boolean,
isArbitraryTarget: boolean = false
): boolean {
if (mergeIntoParent) {
if (!isArbitraryTarget && diff.exists_in_fork === false && !diffForkDroppedItem(diff)) {
return false
}
return diff.ahead > 0
}
return diff.behind > 0 || diffCreatesInTarget(diff, mergeIntoParent)
}
/**
* Delete/archive an item in a workspace.
* Used when deploying a deletion from one workspace to another.
* Scripts and flows are archived (reversible). Other types are deleted.
*/
export async function deleteItemInWorkspace(
kind: Kind,
path: string,
workspace: string
): Promise<DeployResult> {
return sharedDeleteItem(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* Check if an item already exists in the target workspace.
*/
export async function checkItemExists(
kind: Kind,
path: string,
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<boolean> {
if (kind === 'trigger') {
if (!additionalInformation?.triggers) {
throw new Error('Missing triggers kind for legacy trigger deploy')
}
return existsTrigger({ workspace, path }, additionalInformation.triggers.kind)
}
return sharedCheckItemExists(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* Get the value of an item for diff comparison.
*/
export async function getItemValue(
kind: Kind,
path: string,
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<unknown> {
if (kind === 'trigger') {
if (!additionalInformation?.triggers) return {}
try {
return await getTriggerValue(additionalInformation.triggers.kind, path, workspace)
} catch {
return {}
}
}
return sharedGetItemValue(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* Get the on_behalf_of value for a deployable item.
*/
export async function getOnBehalfOf(
kind: Kind,
path: string,
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<string | undefined> {
if (kind === 'trigger' && additionalInformation?.triggers) {
try {
return await getTriggerPermissionedAs(additionalInformation.triggers.kind, path, workspace)
} catch {
return undefined
}
}
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* `getOnBehalfOf` without its swallow-and-return-undefined. A caller that offers an identity choice
* only when the source has one must be able to tell "carries no identity" from "could not read it":
* conflating them silently reassigns the deployed item to whoever deployed it.
*/
export async function getOnBehalfOfOrThrow(
kind: 'script' | 'flow' | 'app' | 'raw_app',
path: string,
workspace: string
): Promise<string | undefined> {
const provider = makeProvider()
if (kind === 'flow') return (await provider.getFlowByPath({ workspace, path })).on_behalf_of_email
if (kind === 'script')
return (await provider.getScriptByPath({ workspace, path })).on_behalf_of_email
return (await provider.getAppByPath({ workspace, path })).policy?.on_behalf_of_email
}
/**
* Every workspace group, not just the first page.
*
* `listGroupNames` would be the obvious call but unions in instance groups, which folder rules do
* not resolve against — a same-named instance group would let an unusable rule through. `listGroups`
* reads the workspace's own `group_` rows, which is what the server checks, but it paginates: a
* group missed here reads as "no account in the target", which now refuses a folder copy outright.
*/
async function workspaceGroupNames(workspace: string): Promise<Set<string>> {
const PER_PAGE = 1000
const names = new Set<string>()
// Stops on a short page; the size check is the backstop for a server that ignores `page`.
for (let page = 1; page <= 50; page++) {
const batch = await GroupService.listGroups({ workspace, page, perPage: PER_PAGE })
const before = names.size
batch.forEach((g) => names.add(g.name))
if (batch.length < PER_PAGE || names.size === before) break
}
return names
}
/**
* Resolve a source-workspace principal into the same person or group as the target names them.
*
* A `u/<username>` is workspace-local: the same username in the target can be a different account,
* so copying one verbatim can hand a folder — or an item's execution identity — to a namesake. Email
* is the only identifier stable across workspaces, so users go source username -> email -> target
* username, and anyone without an account there resolves to undefined for the caller to deal with.
*/
async function principalTranslator(workspaceFrom: string, workspaceTo: string) {
const [fromUsers, toUsers, targetGroups] = await Promise.all([
// `list_users` is unpaginated, unlike the group listing below.
UserService.listUsers({ workspace: workspaceFrom }),
UserService.listUsers({ workspace: workspaceTo }),
workspaceGroupNames(workspaceTo)
])
const emailOfSourceUsername = new Map(fromUsers.map((u) => [u.username, u.email]))
const targetUsernameOfEmail = new Map(toUsers.map((u) => [u.email, u.username]))
/** The same principal as `workspaceTo` names it, or undefined when it has no account there. */
return (principal: string): string | undefined => {
if (principal.startsWith('u/')) {
const email = emailOfSourceUsername.get(principal.slice(2))
const username = email ? targetUsernameOfEmail.get(email) : undefined
return username ? `u/${username}` : undefined
}
if (principal.startsWith('g/')) {
return targetGroups.has(principal.slice(2)) ? principal : undefined
}
// An email is already workspace-independent; it only has to name someone there.
return targetUsernameOfEmail.has(principal) ? principal : undefined
}
}
export type CreateFolderResult = DeployResult & {
/** Access dropped because its principal has no account in the target, if any. */
droppedAccess?: string[]
}
/**
* Copy a folder into `workspaceTo`, creating it and never updating it.
*
* `deployItem` re-probes and switches to `updateFolder` when the folder turns out to exist, which
* would replace its owners and ACL with the source's. For a folder the user asked to deploy that is
* the point; for one created on their behalf to give an item somewhere to land it would silently
* rewrite the permissions of a folder someone else just created. Losing that race is success here —
* the folder exists, which is all the caller needed.
*
* Every principal is translated into the target's own naming (see `principalTranslator`), and the
* two kinds of unresolvable principal are treated differently because they fail differently:
*
* - an **owner or ACL entry** with no account in the target is dropped. The folder ends up more
* restrictive than its source, never less, and `create_folder` makes the caller an owner, so
* nobody is locked out of what they just created.
* - an **identity rule** with no account in the target refuses the whole copy. Dropping it would
* leave the folder applying no rule where the source applied one, so an item landing inside runs
* as whoever deployed it — the silent substitution this prompt exists to prevent — and carrying
* it verbatim is worse still: the server validates a rule's shape at folder-create time but its
* principal's existence at item-create time, so the folder would be created and then reject
* every deploy into it, including the retry.
*
* `default_permissioned_as` and `labels` are carried at all, which the shared folder deploy drops.
*/
export async function createFolderIfAbsent(
name: string,
workspaceFrom: string,
workspaceTo: string
): Promise<CreateFolderResult> {
try {
const folder = await FolderService.getFolder({ workspace: workspaceFrom, name })
const rules = folder.default_permissioned_as ?? []
const owners = folder.owners ?? []
const acl = Object.entries((folder.extra_perms ?? {}) as Record<string, boolean>)
const translate = await principalTranslator(workspaceFrom, workspaceTo)
const unresolvableRule = rules.map((r) => r.permissioned_as).find((p) => !translate(p))
if (unresolvableRule) {
return {
success: false,
error:
`f/${name} runs items on behalf of ${unresolvableRule}, which has no account in ` +
`the target workspace. Bring the folder across from the compare page first.`
}
}
const droppedAccess = [...owners, ...acl.map(([p]) => p)].filter((p) => !translate(p))
await FolderService.createFolder({
workspace: workspaceTo,
requestBody: {
name,
owners: owners.map(translate).filter((p): p is string => !!p),
extra_perms: Object.fromEntries(
acl.flatMap(([p, write]) => {
const t = translate(p)
return t ? [[t, write] as const] : []
})
),
summary: folder.summary ?? undefined,
default_permissioned_as: rules.map((r) => ({
...r,
permissioned_as: translate(r.permissioned_as)!
})),
labels: folder.labels
}
})
return { success: true, droppedAccess: droppedAccess.length ? droppedAccess : undefined }
} catch (e) {
// The name conflict a concurrent create produces is not part of the API contract, so ask
// again rather than matching its message.
try {
if (await checkItemExists('folder', `f/${name}`, workspaceTo)) return { success: true }
} catch {}
return { success: false, error: `${e}` }
}
}
/** Which term refused, so a caller can scope a refusal the server applies to only some kinds. */
export type DeployRefusal = 'operator' | 'DisableDirectDeployment' | 'RestrictDeployToDeployers'
export type DeployPermission = { ok: boolean; reason?: string; refusedBy?: DeployRefusal }
// The server reaches `check_deploy_rules` from the item handlers, and only these kinds call it
// (windmill-api-{scripts,flows,groups}, windmill-api/src/apps.rs, windmill-store/src/{resources,
// variables}.rs). Schedules and triggers hit no gate at all, so a protection rule must not
// disable them here. Exhaustive by construction: a new `Kind` fails to compile without a verdict.
const KIND_GATED_BY_DEPLOY_RULES: Record<Kind, boolean> = {
script: true,
flow: true,
app: true,
raw_app: true,
resource: true,
resource_type: true,
variable: true,
folder: true,
schedule: false,
http_trigger: false,
websocket_trigger: false,
kafka_trigger: false,
nats_trigger: false,
postgres_trigger: false,
mqtt_trigger: false,
amqp_trigger: false,
sqs_trigger: false,
gcp_trigger: false,
azure_trigger: false,
email_trigger: false,
datatable_migration: false,
trigger: false,
data_pipeline: false
}
// Every gated kind is spelled identically in `Kind` and `UserDraftItemKind`, so one lookup serves
// both taxonomies and the drafts surface needs no bridge. The kinds whose spellings diverge
// (`trigger_http` vs `http_trigger`) are exactly the ungated ones — so if a trigger kind ever
// becomes gated server-side, it needs its draft spelling added here too.
const GATED_KIND_NAMES = new Set(
Object.entries(KIND_GATED_BY_DEPLOY_RULES)
.filter(([, gated]) => gated)
.map(([kind]) => kind)
)
export function kindGatedByDeployRules(kind: Kind | UserDraftItemKind): boolean {
return GATED_KIND_NAMES.has(kind)
}
/**
* 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
* it here would change behaviour beyond mirroring the server.
*/
export function deployPermissionForKind(
perm: DeployPermission,
kind: Kind | UserDraftItemKind
): DeployPermission {
if (perm.ok) return perm
if (perm.refusedBy === 'DisableDirectDeployment' && !kindGatedByDeployRules(kind)) {
return { ok: true }
}
return perm
}
/** 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)[]
): DeployPermission {
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.
if (kinds.length === 0) return perm
return kinds.some((k) => !deployPermissionForKind(perm, k).ok) ? perm : { ok: true }
}
/**
* Whether the current user may deploy into `workspace`. Mirrors `check_deploy_rules` in
* windmill-common so the UI can disable the action with a reason instead of letting the
* click 403: `DisableDirectDeployment` is evaluated before `RestrictDeployToDeployers`, so
* the same message wins here as on the server when both block; admins and superadmins bypass
* both rules, while `wm_deployers` members bypass only the latter.
*
* The operator refusal is not part of that mirror. The server refuses operators 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.
*
* Fails open on any error — the server still enforces on the actual deploy.
* Shared by the session dock and the compare page so both gate identically.
*/
export async function checkDeployPermission(
workspace: string,
/** Pre-fetched `whoami` for `workspace`, to save a round trip when the caller already has one. */
whoami?: User
): Promise<DeployPermission> {
try {
const me = whoami ?? (await UserService.whoami({ workspace }))
if (me.operator) {
return {
ok: false,
reason: "You're an operator in this workspace — operators can't deploy",
refusedBy: 'operator'
}
}
const userInfo = {
is_admin: !!me.is_admin,
is_super_admin: !!me.is_super_admin,
username: me.username,
groups: me.groups ?? []
}
// A superadmin who is only a plain member of the workspace still bypasses every rule,
// so dropping either term here refuses a deploy the server accepts.
if (!userInfo.is_admin && !userInfo.is_super_admin) {
const rulesets = await fetchProtectionRulesForWorkspace(workspace)
if (!canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', userInfo)) {
// The reserved dev-workspace lock carries DisableWorkspaceForking alongside this rule,
// so suggesting a fork unconditionally would point at a second blocked action.
const canFork = canUserBypassRuleKindInRulesets(
rulesets,
'DisableWorkspaceForking',
userInfo
)
const advice = canFork
? 'fork the workspace or open a pull request'
: 'make your changes locally and open a pull request'
return {
ok: false,
reason: `Direct deployment to ${workspace} is disabled — ${advice}`,
refusedBy: 'DisableDirectDeployment'
}
}
// `wm_deployers` membership is an implicit pass on this rule only, so it cannot
// short-circuit the fetch above the way admin does.
const isDeployer = userInfo.groups.includes('wm_deployers')
if (
!isDeployer &&
!canUserBypassRuleKindInRulesets(rulesets, 'RestrictDeployToDeployers', userInfo)
) {
return {
ok: false,
reason: `Only workspace admins and members of wm_deployers can deploy to ${workspace}`,
refusedBy: 'RestrictDeployToDeployers'
}
}
}
return { ok: true }
} catch {
return { ok: true }
}
}
/**
* Whether `me` may write at `path` in `workspace` — the per-item half of the deploy gate, which
* `checkDeployPermission`'s workspace-level rules don't cover. Advisory only: it exists so the UI
* can refuse with a reason instead of letting the write fail, and the server stays the enforcement
* point. Same fail-open contract — every uncertain answer is `ok`.
*
* `me.folders` is the target workspace's *write* set, so a folder absent from it is one this user
* can read at most. `folderExists` is injected so the decision can be exercised without a backend.
*/
export async function checkPathWritePermission(
workspace: string,
path: string,
me: Pick<User, 'is_admin' | 'is_super_admin' | 'username' | 'folders'>,
folderExists: (folderPath: string) => Promise<boolean> = (folderPath) =>
checkItemExists('folder', folderPath, workspace)
): Promise<DeployPermission> {
// The server's `is_owner` reads `ApiAuthed.is_admin`, the merged `is_admin || super_admin`,
// so a superadmin owns every path here whether or not they own the folder.
if (me.is_admin || me.is_super_admin) return { ok: true }
const owner = path.match(/^u\/([^/]+)\//)?.[1]
if (owner) {
return owner === me.username
? { ok: true }
: {
ok: false,
reason: `${path} is owned by u/${owner} — only they or a workspace admin can write there`
}
}
const folder = path.match(/^f\/([^/]+)\//)?.[1]
if (!folder || me.folders?.includes(folder)) return { ok: true }
try {
// A folder the target doesn't have yet is created by the deploy, with the deployer as its
// owner — lacking write access to something that doesn't exist isn't a refusal.
if (!(await folderExists(`f/${folder}`))) return { ok: true }
} catch {
// Inconclusive: let the deploy decide rather than refusing on a failed probe.
return { ok: true }
}
return { ok: false, reason: `You don't have write access to folder ${folder}` }
}
export type DeployTargetAccess = {
permission: DeployPermission
/** Whether the user may hand the item an identity other than their own. */
canPreserveOnBehalfOf: boolean
/** The caller as `workspace` knows them — usernames are per-workspace, emails are not. */
me?: AppIdentity
}
/**
* What the target workspace says about landing one item in it: the workspace-level gate, write
* access to the item's path, and whether another identity may be preserved. Bundled so one `whoami`
* answers all of it, and so a refusal is known before the deploy rather than as a 403 on confirm.
*/
export async function checkItemDeployAccess(
workspace: string,
path: string
): Promise<DeployTargetAccess> {
let me: User
try {
me = await UserService.whoami({ workspace })
} catch {
return { permission: { ok: true }, canPreserveOnBehalfOf: false }
}
const workspaceLevel = await checkDeployPermission(workspace, me)
return {
permission: workspaceLevel.ok
? await checkPathWritePermission(workspace, path, me)
: workspaceLevel,
canPreserveOnBehalfOf:
me.is_admin || me.is_super_admin || (me.groups ?? []).includes('wm_deployers'),
me: { email: me.email, permissionedAs: `u/${me.username}` }
}
}