Files
windmill/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts
T
AlexRV12andClaude Opus 5 ef8a8e821c fix: check direct-deployment lock and superadmin in the deploy preflight (#10748)
* fix: check direct-deployment lock and superadmin in the deploy preflight

`checkDeployPermission` mirrors the server's `check_deploy_rules` so the deploy
UI can disable an action with a reason instead of letting the click come back
403. It modelled only `RestrictDeployToDeployers`, leaving two terms out:

- `DisableDirectDeployment` was never evaluated. In a workspace carrying only
  that rule the preflight allowed the deploy and the request 403'd.
- The server bypasses on `ApiAuthed.is_admin`, which is `usr.is_admin ||
  super_admin`, while `whoami` reports the two separately. A superadmin who is
  a plain member of the workspace was refused a deploy the server allows.

Evaluate `DisableDirectDeployment` first, as the server does, so the same
message wins when both rules block, and add the superadmin term to the shared
ruleset bypass helper. `wm_deployers` membership is an implicit pass on
`RestrictDeployToDeployers` alone, so it no longer short-circuits the rules
fetch the way admin does — a deployer is still bound by a direct-deployment
lock, and a test pins that.

The operator refusal stays above the admin/superadmin short-circuit: the server
refuses operators in the item handlers whatever their global role, so a
superadmin who is an operator in the workspace is still refused. Its doc no
longer presents that term as part of the `check_deploy_rules` mirror, since the
rule carries no operator term and refusing every kind here is deliberately
stricter than the server.

Callers no longer name which rules the preflight covers. That list rots at every
site that repeats it, so it lives only at the preflight itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: apply the direct-deployment refusal only to the kinds the server gates

`check_deploy_rules` runs from the item handlers, and only scripts, flows, apps,
resources, resource types, variables and folders reach it. Schedules and
triggers hit no gate at all: in a `DisableDirectDeployment` workspace the server
returns 200 for a schedule and 403 for a script.

The preflight answers per workspace, and that one answer disabled the deploy
action for every kind, so adding the direct-deployment term would have blocked
schedule and trigger deploys the server accepts. Tag each refusal with the term
that produced it and let callers narrow a direct-deployment refusal to the kinds
the server actually gates; a selection still blocks as soon as one gated kind is
in it.

The deployers-only term keeps applying to every kind. It over-reaches the same
way, but narrowing it would loosen the UI beyond mirroring the new rule, so it
stays as it is and no existing behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: mirror the superadmin bypass in the per-item deploy checks too

`checkPathWritePermission` and `canPreserveOnBehalfOf` still tested `is_admin`
alone. The server reads the merged `ApiAuthed.is_admin` in both places —
`is_owner` for path ownership and `can_preserve_on_behalf_of` for the deploy
identity — so a superadmin who is a plain member was refused a write the server
accepts: creating a script in a folder owned by someone else returns 201 for
them.

Also drop the rule enumeration from the session deploy guard's comment, which
named the operator and deployer rules for a preflight that now covers the
direct-deployment lock and answers per kind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the deploy refusal on an empty selection and match the advice to the fork lock

* fix: mirror the superadmin bypass in the compare page's on-behalf-of gate

* docs: name the variable that tracks the deploy direction

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:01:46 +02:00

369 lines
13 KiB
TypeScript

import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import {
checkDeployPermission,
checkItemExists,
deployPermissionForKind,
getItemValue,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
import type { Kind } from '$lib/utils_deployable'
import {
deployDraft,
discardDraft,
fetchDraftBaseStale,
getDraftDiffValues
} from '$lib/utils_draft_deploy'
import { untrack } from 'svelte'
import {
buildDeployItems,
deployPlanFor,
diffBaseFor,
discardPlanFor,
maskOnlyCandidates,
type DeployItem,
type DeployPlanEntry
} from './sessionDeployModel'
import { maskKey } from './modifiedItemsMask'
import { sessionState } from './sessionState.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
export type DeploymentStatus = { status: 'loading' | 'failed'; error?: string }
// Reactive wrapper over the pure sessionDeployModel: owns the async draft list
// and builds the unified item list. The diff-value loader lives here too so both
// the tree and the diff column read one resolver. The dock is granular per-item
// deploy (deployRow/discardRow); batch flows and fork→parent promotion are the
// compare page's concern.
export interface SessionDeployModelArgs {
workspaceId: string
/** Called after a deploy/discard mutated workspace state. Lets the consumer
* refresh ITS OWN data sources — without this, the bar shows stale counts
* until the next turn end / tab refocus / reload. */
onDataChanged?: () => void
/** A draft deployed: the consumer owning the mask moves the item's entry to
* its deployed path (`displayPath` — differs from the synthetic storage
* `path` for draft-only items, which never exists deployed). */
onItemDeployed?: (item: DeployItem) => void
/** A draft was discarded: the chat's touch is undone — the consumer drops
* the item's mask entry so a pre-existing deployed item doesn't keep
* reading as this chat's "Deployed" edit. */
onItemDiscarded?: (item: DeployItem) => void
/** Chat-modified-items mask; undefined shows every draft. */
mask?: Set<string>
}
export interface DiffValues {
before: unknown
after: unknown
}
/** Resolves which mask-only candidates still exist in the session workspace
* (deployed) vs are gone (discarded drafts / deleted items) — the
* `existingKeys` input of `buildDeployItems`. Undefined while resolving, so
* deployed rows only appear once confirmed. Shared by the drawer model and the
* bar's dock counts. */
export function useExistingMaskKeys(
getInput: () => Omit<Parameters<typeof buildDeployItems>[0], 'existingKeys'> & {
workspaceId: string
}
) {
let existingKeys = $state<Set<string> | undefined>(undefined)
// Existence is checked once per distinct candidate set; the signature avoids
// re-firing while results stream back.
let lastCandidateSig = ''
// Bumped by refresh() to force a re-check even when the candidate set (the
// effect's other dependency) is unchanged.
let recheck = $state(0)
$effect(() => {
void recheck
const { workspaceId, ...buildInput } = getInput()
const cands = maskOnlyCandidates(buildInput)
// The workspace is part of the identity: the same candidate keys in a
// different workspace must re-check, not reuse the previous verdict.
const sig =
workspaceId +
'|' +
cands
.map((c) => c.key)
.sort()
.join(',')
untrack(() => {
if (sig === lastCandidateSig) return
lastCandidateSig = sig
if (cands.length === 0) {
existingKeys = new Set()
return
}
// A mask-only item that was deployed still exists in the session
// workspace; a discarded or deleted one doesn't.
void Promise.all(
cands.map((c) =>
checkItemExists(c.deployKind, c.path, workspaceId)
.then((exists) => (exists ? c.key : null))
.catch(() => null)
)
).then((keys) => {
// A slower batch for a superseded candidate set (or one voided by
// reset()) must not overwrite the current result.
if (sig !== lastCandidateSig) return
existingKeys = new Set(keys.filter((k): k is string => !!k))
})
})
})
return {
get keys() {
return existingKeys
},
/** Force a fresh existence check (an item may have been deleted since).
* Hard invalidate: current keys are dropped until the check resolves, so
* rows derived from them disappear meanwhile — use on a fresh surface
* (drawer open), not on a live one. */
reset() {
lastCandidateSig = ''
existingKeys = undefined
},
/** Re-check while keeping the current keys (stale-while-revalidate), so a
* live surface doesn't blank out during the round-trip. */
refresh() {
lastCandidateSig = ''
recheck++
}
}
}
export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
let draftItems = $state<DraftItem[]>([])
let loading = $state(false)
let error = $state<string | undefined>(undefined)
// Mask keys confirmed to still exist → allowed to show as terminal deployed
// rows (a discarded draft is a mask entry with no item, so it stays out).
const existing = useExistingMaskKeys(() => ({
draftItems,
mask: getArgs().mask,
workspaceId: getArgs().workspaceId
}))
// Optimistic knowledge from this model's own deploys: a deployed key exists
// in the session workspace before the async existence check confirms it.
// Without this a deployed row falls out of the list for ~1s, unmounting its
// card.
let deployedKeys = $state<ReadonlySet<string>>(new Set())
const items = $derived.by(() => {
const existingKeys =
deployedKeys.size === 0 ? existing.keys : new Set([...(existing.keys ?? []), ...deployedKeys])
return buildDeployItems({ draftItems, mask: getArgs().mask, existingKeys })
})
async function fetchDrafts() {
loading = true
error = undefined
try {
draftItems = await getDraftItems(getArgs().workspaceId)
} catch (e) {
console.error('Session deploy model: draft list failed', e)
error = `Failed to load drafts: ${e}`
draftItems = []
} finally {
loading = false
}
}
/** (Re)fetch. Called on open. */
function load() {
// Re-check existence fresh (an item may have been deleted since last open).
existing.reset()
// Drop the optimistic bridge with it: it only covers the gap until the
// fresh check resolves, and a stale entry would keep a since-deleted item
// reading as deployed forever.
deployedKeys = new Set()
// Staleness can change between opens (a deploy elsewhere moves the head).
staleFetched.clear()
staleKeys = new Set()
void fetchDrafts()
}
// ── Stale drafts (base ≠ deployed head) ──────────────────────────────────
// A draft forked from a version that is no longer the deployed head: someone
// deployed in between, so deploying the draft would silently revert them.
// Same detection as the compare page; surfaced as a warning, deploy stays
// allowed. Only own drafts on deployed script/flow/app items carry a base.
let staleKeys = $state<ReadonlySet<string>>(new Set())
const staleFetched = new Set<string>()
$effect(() => {
const list = items
const ws = getArgs().workspaceId
untrack(() => {
for (const it of list) {
if (!it.hasDraft || it.draftOnly || !it.mine) continue
if (!['script', 'flow', 'app', 'raw_app'].includes(it.draftKind)) continue
if (staleFetched.has(it.key)) continue
staleFetched.add(it.key)
void fetchDraftBaseStale(it.draftKind, it.path, ws).then((stale) => {
if (stale) staleKeys = new Set(staleKeys).add(it.key)
})
}
})
})
// ── Deploy permission ────────────────────────────────────────────────────
// Preflight the shared checkDeployPermission for the session workspace so the
// button disables with a reason instead of failing on click. `ok` defaults
// true while resolving (fail-open).
let deployPerm = $state<DeployPermission>({ ok: true })
let deployPermFetchedFor = ''
$effect(() => {
const ws = getArgs().workspaceId
untrack(() => {
if (!ws || deployPermFetchedFor === ws) return
deployPermFetchedFor = ws
// Reset to fail-open for the new workspace and drop a stale resolution
// (a slower fetch for the previous workspace must not gate this one).
deployPerm = { ok: true }
void checkDeployPermission(ws).then((perm) => {
if (deployPermFetchedFor === ws) deployPerm = perm
})
})
})
// ── Deploy execution ─────────────────────────────────────────────────────
// Per-item transient deploy state (keyed by DeployItem.key): loading or
// failed. Success has no status — the row's badge flipping to Deployed is
// the feedback. Reassigned, not mutated.
let deploymentStatus = $state<Record<string, DeploymentStatus>>({})
let deploying = $state(false)
function setStatus(key: string, s: DeploymentStatus | undefined) {
const next = { ...deploymentStatus }
if (s) next[key] = s
else delete next[key]
deploymentStatus = next
}
async function runPlan(entry: DeployPlanEntry): Promise<DeployResult> {
const cur = getArgs().workspaceId
switch (entry.op) {
case 'deploy_draft':
return deployDraft(entry.draftKind, entry.path, cur, {
draftOnly: entry.draftOnly,
rawApp: entry.rawApp
})
case 'discard':
return discardDraft(entry.draftKind, entry.path, cur, entry.draftOnly, entry.legacy)
}
}
function refreshData() {
void fetchDrafts()
getArgs().onDataChanged?.()
}
/** Deploy (or discard) a single item; returns whether it succeeded. */
async function deployOne(item: DeployItem, discard = false): Promise<boolean> {
const plan = discard ? discardPlanFor(item) : deployPlanFor(item)
if (!plan) return false
// Snapshot before the await: the user may switch sessions while the
// deploy runs, and the event belongs to the initiating session.
const initiatingSessionId = sessionState.currentSessionId
// Don't attempt a deploy we know the user can't make (no write permission on
// the path, or refused by the preflight for this kind) — the UI disables it
// too; this is the guard behind that.
if (!discard && (!item.canWrite || !deployPermissionForKind(deployPerm, item.deployKind).ok))
return false
setStatus(item.key, { status: 'loading' })
deploying = true
try {
const res = await runPlan(plan)
setStatus(item.key, res.success ? undefined : { status: 'failed', error: res.error })
if (res.success) {
if (discard) {
getArgs().onItemDiscarded?.(item)
} else {
// Bridge under the DEPLOYED path's key too: the mask rewrite
// (onItemDeployed) re-keys a synthetic-storage row to displayPath.
deployedKeys = new Set(deployedKeys)
.add(item.key)
.add(maskKey(item.draftKind, item.displayPath))
getArgs().onItemDeployed?.(item)
logFeatureUsage('ai_session', 'deployed', {
key: item.draftKind,
entityId: initiatingSessionId,
workspace: getArgs().workspaceId
})
}
}
return res.success
} finally {
deploying = false
}
}
/** Returns success so the UI can stage its post-deploy animation. */
async function deployRow(item: DeployItem): Promise<boolean> {
const ok = await deployOne(item)
if (ok) refreshData()
return ok
}
async function discardRow(item: DeployItem) {
if (await deployOne(item, true)) refreshData()
}
// ── Diff values (one resolver for tree + column) ─────────────────────────
async function loadDiffValues(item: DeployItem): Promise<DiffValues> {
const base = diffBaseFor(item, getArgs().workspaceId)
if (base.kind === 'draft') {
const { deployed, draft } = await getDraftDiffValues(
base.draftKind,
item.path,
base.workspaceId,
base.draftOnly
)
return { before: base.draftOnly ? undefined : deployed, after: draft }
}
const v = await getItemValue(base.deployKind, base.path, base.workspaceId).catch(
() => undefined
)
return { before: v, after: v }
}
return {
get items() {
return items
},
get loading() {
return loading
},
get error() {
return error
},
load,
loadDiffValues,
get deploying() {
return deploying
},
statusOf(key: string): DeploymentStatus | undefined {
return deploymentStatus[key]
},
/** Whether a draft row's base is stale (deployed head moved since). */
staleOf(key: string): boolean {
return staleKeys.has(key)
},
/**
* Whether the user may deploy into the session workspace. Per-kind, because a
* direct-deployment lock never reaches schedules or triggers server-side — a row of
* that kind stays deployable while a script row does not.
*/
deployPermissionForKind(kind: Kind): DeployPermission {
return deployPermissionForKind(deployPerm, kind)
},
deployRow,
discardRow
}
}
export type SessionDeployModel = ReturnType<typeof useSessionDeployModel>