Commit Graph
4 Commits
Author SHA1 Message Date
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
GuilhemandClaude Opus 5 616d4fe167 fix: edit-in-dev-workspace dead-ends, wraps, and misses the tree view (#10354)
* fix: stop the homepage edit-in-fork button from wrapping

* fix: show the full edit-in-fork label anywhere on the button

* fix: thread showEditButton through the homepage tree view

* fix: match the edit-in-fork button styling to the normal edit button

* fix: edit in dev workspace dead-ends on items the dev workspace lacks

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

* fix: pull the item's folder before copying it into the dev workspace

* fix: speak the compare page's update vocabulary in the dev-workspace prompt

* fix: raw app with no stylesheet was undeployable across workspaces

* fix: drop the raw-app stylesheet workaround now that the backend serves one

The frontend wrapped `getRawAppData` to report a missing `.css` as empty,
because a raw app with no stylesheet stores no css blob and the shared deploy
treats the resulting 404 as fatal. #10364 fixed that at the source: the backend
now serves an empty body for a missing stylesheet, so the wrapper guards a 404
that no longer happens.

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

* fix: drop the fork icon from the edit-in-dev-workspace affordances

The row button carried both a pen and a fork, and the menu entries and detail
page buttons carried a fork alone — where the menus already used that same icon
for Duplicate/Fork, so the two entries were indistinguishable. The action is an
edit, so it takes the pen everywhere, matching the ordinary Edit button.

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

* fix: send edit in dev workspace to the item's editor

The affordance landed on the item's page in the dev workspace and left the user
to open the editor from there. It says "Edit", so it goes to the editor:
`/scripts/edit/...?workspace=<dev>` and the equivalent for flows and both app
kinds. `?workspace=` still does the workspace switch, which the logged layout
applies on any route.

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

* feat: choose the on-behalf-of user when updating the dev workspace

The prompt deployed the item with no say over the identity it would run under,
so an item that ran on behalf of someone in prod silently became the deploying
user's in the dev workspace. It now offers the same choice the compare page
does, under the same rules: shown only when the source item carries an
on_behalf_of, picking anyone but yourself gated on admin/wm_deployers in the
target, and confirming blocked until a choice is made — including while the
lookup that decides whether one is needed is still in flight.

The prompt also stops offering the compare page inline; the confirm button
still leads there when the user can't deploy into the dev workspace.

Two fixes the reused selector needed to work inside a dialog:
- ConfirmationModal takes `confirmDisabled`, which also blocks the Enter binding.
- The popover's z-index is now overridable, and the user picker is portalled.
  A ConfirmationModal renders above the popover layer, and its card is
  transformed for the open transition, which makes it the containing block for
  the picker's `fixed` positioning — so both opened behind, and the picker was
  laid out inside the card instead of the viewport.

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

* fix: check deploy rights per item before prompting to update the dev workspace

* fix: read the compare page link before closing the dev-workspace prompt

The link is derived from the request the prompt is answering, so closing first
left an empty string to navigate to: refusing users saw the dialog dismiss and
stay put, with no way through to the compare page.

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

* fix: keep the new-tab promise and speak up when a popup is blocked

Three defects found by successive cold reviews of the click-time resolution
added earlier in this branch, each one only reachable once the previous fix
existed:

- Safari refuses `window.open` from any promise continuation however fast it
  resolves, so the tab the editor dropdown opens after its existence probe
  never appeared there. `claimTab` takes the tab inside the click's own
  transient activation and points it at the answer once it lands, releasing it
  when there is nothing to show.
- That left the two halves of the same action disagreeing: the entry promises
  never to navigate the editor away, but when the item turned out to be missing
  the prompt took over and navigated in place. The request now carries
  `openInNewTab`, and every destination the prompt can reach honours it.
- With popups blocked the fallback called `window.open` without checking, so a
  successful deploy closed the prompt and did nothing, silently. It now names
  what it could not open.

`openEditInFork` also takes the workspace explicitly. The four editor dropdowns
computed their label from `opWorkspace` but resolved the action from the
navigation store, and `prodWorkspaceId` feeds `deployItem({ workspaceFrom })` —
so a session pane would have deployed from the wrong workspace.

`checkPathWritePermission` is exported with an injectable folder probe and
covered by table-driven cases, chiefly to pin its two fail-open branches, which
otherwise read as dead code inviting deletion.

The two unrelated whitespace hunks in ScriptBuilder.svelte are the repo's
format-on-save hook fixing pre-existing violations in a file this touched.

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

* fix: create the dev workspace's missing folder without overwriting it

`ensureFolder` delegated to the shared `deployItem`, which re-probes and
switches to `updateFolder` when the folder turns out to exist. Nobody asked for
that folder to be deployed — it is created only so the item has somewhere to
land — so a folder created between the two probes had its owners, ACL, summary
and labels silently replaced with the source workspace's. Creating is now
create-only, and losing that race counts as success: the folder exists, which is
all the caller needed.

The same delegation dropped `default_permissioned_as` and `labels`, which the
shared folder deploy does not send. A folder copied without its create-time
identity rules applies none, so an item landing inside it with no on_behalf_of
of its own resolves to whoever deployed it rather than to the principal the
source folder would have chosen — the exact substitution the rest of this branch
exists to prevent. Both are now carried across.

Also check `window.open` in the no-dev-workspace branch of `openEditInFork`. The
branch beneath it already reported a blocked popup; this one returned as if it
had opened something.

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

* fix: translate copied folder identity rules into the target workspace

A `u/<username>` names a workspace-local account, so copying a folder's
`default_permissioned_as` verbatim was wrong in two directions: the same
username in the dev workspace can be a different person, who would then be
granted the item; and a username with no account there at all passes the
folder-create check, which is structural, only to fail every subsequent item
deploy on the existence check, including the retry — the folder now exists, so
`ensureFolder` short-circuits and the deploy fails identically, with no way out
of the prompt.

Rules are now resolved source username -> email -> target username, since email
is the only identifier stable across workspaces, and a rule whose principal has
no account in the target is dropped rather than carried. Dropping one makes the
copied folder less restrictive than its source, which is not something to
discover later from an item running as the wrong user, so it is reported.

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

* fix: refuse to overwrite a concurrent item, and translate every folder principal

Four findings from CI review, all on the implicit half of this flow — the writes
the user did not explicitly ask for.

The item write is now create-only. The shared `deployItem` re-probes and silently
switches to an update, so the caller that acts on an item being *absent* could
still overwrite whoever landed it between the two probes. Rather than
reimplementing the per-kind deploys, the frontend's own provider refuses exactly
the three writes that branch reaches for — `updateFlow`, `updateApp`/
`updateAppRaw`, and a `createScript` carrying a `parent_hash`, which is what
makes an otherwise identical create an update. A refusal reports `conflict`, and
the prompt opens their version instead of replacing it.

Folder principals are translated rather than copied. `u/<username>` is
workspace-local, so a verbatim copy either names nobody or names a different
account that happens to share the username. Users now resolve source username ->
email -> target username, and the two kinds of unresolvable principal are
separated because they fail differently: an owner or ACL entry is dropped, which
can only narrow the folder and leaves the creator owning it; an identity rule
refuses the copy outright, because dropping it runs the item as the deployer and
keeping it creates a folder the server then rejects every deploy into.

Groups resolve against `listGroups` rather than `listGroupNames`, which unions in
instance groups that folder rules do not resolve against — a same-named instance
group would otherwise let an unusable rule through.

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

* fix: read every page of workspace groups before judging a folder principal

`listGroups` paginates, and the `perPage: 100` it was called with is narrower
than the server's own default of 1000 — so a group past the first page read as
having no account in the target. Since an unresolvable identity rule now refuses
the whole folder copy, that turned into a refusal naming a group that does
exist, and an owner or ACL entry on a later page was dropped silently. Read
until a page comes back short, with a size check as the backstop for a server
that ignores `page`.

`list_users` is unpaginated, so the user half of the same lookup was never
affected.

Also move `makeProvider`'s doc block back onto `makeProvider`; adding
`DeployConflict` had left it documenting the type instead.

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

* docs: reattach principalTranslator's doc block to principalTranslator

Adding `workspaceGroupNames` above it left the block documenting the helper,
the same way adding `DeployConflict` had displaced `makeProvider`'s.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:21:14 +02:00
f365929eaa feat(fork): merge a fork deletion on evidence, not on the counters (#10484)
* feat(fork): merge a fork deletion on evidence, not on the counters

`workspace_diff.ahead`/`.behind` record that a write happened on a side,
not what it was or who made it. That leaves one row shape undecidable: an
item the parent has and the fork does not can mean the parent added it,
the fork deleted it, or a git-sync pull reverted a deploy that had just
brought it in. #10467 kept every such row out of the merge direction,
which killed the phantom but also dropped the only way to propagate a
fork-side deletion and left a rename's old path behind in the parent.

Record the evidence instead:

- `workspace_diff` gains, per side, the last event's kind (`write` /
  `delete` / `rename_from`) and origin (`authored` / `sync`). Rows
  written before the migration have neither and keep #10467's behavior.
- The kind is probed from whether the path still holds an item once the
  write has committed; an item kind the probe doesn't map records no
  evidence rather than a deletion. Create and update are not split —
  nothing at that point tells them apart for every kind, and the
  comparison already recomputes existence per side.
- The origin comes from an `X-Windmill-Deploy-Origin` header the API
  scopes into a task-local for the request. It is the load-bearing half:
  recording `delete` alone would read a git-sync revert as a fork
  deletion and reproduce the original bug. Two clients set it — `wmill
  sync push` (which the git-sync auto-pull runs inside a job) and the
  compare page's parent→fork "Update fork". Merging the other way stays
  authored so a deletion keeps propagating up a fork chain.
- The merge direction admits a parent-only row only when the fork's last
  event was an authored delete or rename-away. Such a row stays opt-in,
  never bulk-selected, and reads "Removes in <parent>"; the update
  direction keeps offering it back as "New".

A fork deletion and a rename now merge into the parent, a rename leaves
no duplicate behind, and a fork the parent also edited surfaces in both
directions instead of the parent silently winning.

Fixes WIN-2289

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

* fix(fork): address review — detached tallies, enum wire values, doc duplication

Codex P1: a dependency job tallies its deploy whenever it happens to finish,
and the event kind is probed from the state at that moment. If anything
removed the path in between (a git-sync revert), the stale tally read that
deletion as its own and filed it as authored — handing the merge exactly the
removal this is meant to withhold. `tally_deployed_object_changes` now takes
`Option<DeployOrigin>`; `None` bumps the counter and leaves the evidence
columns as the last vouching tally left them, and the worker path passes it.
Covered by extending the removal-origin test: a detached tally after the sync
archive must not disturb `(delete, sync)`.

Also from review:
- `fork_removed_it` compares through `DeployOrigin::as_str()` /
  `DeployEventKind::as_str()` rather than repeating their wire values, so a
  renamed variant can't silently make the predicate always false.
- `deploy_origin`'s module doc no longer claims `sync` is inert: it cannot
  make the merge propose a removal, but it does drop a row out of both sides
  of the `all_ahead_items_visible` comparison.
- `WorkspaceDiffRow` says why only the fork half of the evidence is consumed.
- The delete-vs-revert rationale is stated once (the migration) instead of
  restated in eight files.
- `PATH_KEYED_TABLES` is swept by a test: its query is built at runtime, so a
  wrong table name is not a compile error and would only surface as a failed
  tally for that trigger kind in a fork.

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

* fix(fork): let only a request task vouch for a deploy event

Round 2 found the first fix incomplete. Detaching only the failed/cancelled
dependency path left the common route untouched: a dependency job that
succeeds calls `handle_deployment_metadata` from the worker, where
`deploy_origin::current()` read as `Authored`. A sync archiving the script
while its lock generation was pending then had its deletion probed on
completion and refiled as authored — the same fabricated removal, on the
path most deploys actually take.

`current()` now returns `Option`, `Some` only inside the request scope the
API always enters. Having no scope means "not the task that served this
write", which is true of every worker-side call and needs no marking at the
call site. The integration test drives the real `handle_deployment_metadata`
off a request task instead of the tally directly, and fails without this.

Two more from the same round:

- The script dependency handler passed no `renamed_from`, unlike the flow
  and app handlers next to it. A lock-generating create has no earlier
  tally, so that was the only chance for the path a rename vacated to be
  recorded at all — renames of Python/TS scripts left the old path in the
  parent, which the bash-only manual check missed.
- The tally now drops a `renamed_from` equal to the path itself. Callers
  pass the previous path whether or not the deploy moved the item, so an
  unfiltered one both counted the path twice and stamped it `rename_from`
  when nothing was renamed.

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

* fix(fork): carry a deploy's origin into the dependency job it queues

Round 3 caught the previous fix cutting too deep. Refusing a detached tally
any claim also refused its rename evidence, and a lock-generating deploy has
no other tally — so the `renamed_from` added alongside it was inert, and a
renamed flow, app or Python script still left its old path in the parent
with nothing to merge. Flows and apps always generate, so renames worked
essentially nowhere.

The two capabilities are now separate. `TallyEvidence` says whether the
tallying task served the write (`Served`, may probe what the path holds now)
or is reporting one that committed earlier (`Deferred`, may not), and each
column is written only from a source that answers for it. The origin itself
is a fact of the deploy either way, so the request stamps it into the
dependency job's args and the worker re-enters the scope with it — the last
place that knows it handing it to the only tally that will run.

Also from round 3: `WorkspaceDiffRow`'s event fields skip serializing `None`
rather than emitting `null`, matching what the schema declares (OpenAPI
3.0.3 ignores a `description` sibling of `$ref`, so those moved onto the
shared schemas).

Verified against a live worker: renaming a flow in a fork records
`(rename_from, authored)` on the vacated path and the merge offers its
removal, while the deployed path claims nothing.

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

* fix(fork): mark the CLI's parent-to-fork merge as sync

`wmill workspace merge --direction to-fork` is the CLI's "Update fork" and
deletes items in the fork, but without the marker the compare page sets. Its
deletions were recorded as authored fork decisions, so once the parent
recreated such a path the merge would offer deleting it there.

Also from review: an unrecognized deploy-origin arg now reads as no evidence
rather than as authored — strict where a request header is lenient, since an
unmarked request really is authored but an unreadable stored value is skew.
Reading the arg moved next to `stamp_origin_arg`, the half that writes it, so
the round trip a lock-generating deploy depends on is covered by one test.

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

* fix: drop the imports the shared arg reader made unused

CI compiles with `-D warnings`, so this was four red Backend jobs rather
than a lint.

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

* fix(fork): stop a stale deferred rename from restating a removed path

Nothing orders these events. A tally that served the write made its claim
inside its own commit, but a deferred one reports a write that landed at an
unknown remove. So a lock-generating rename whose dependency job finished
after a sync had removed the vacated path could overwrite `(delete, sync)`
with `(rename_from, authored)` — the path is gone either way, so the merge
would then offer removing it from the parent on the strength of the older
event.

A deferred claim now only writes where the side has none, which is the case
it exists for: a vacated path that nothing else has spoken for. The
regression asserts the ordering directly, and fails without the guard.

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

* fix(fork): record a rename's vacated path from the request that made it

The deferred mechanism could not be made correct, as round 7 showed: its
guard protected an existing row, but that row is deleted as soon as the two
workspaces agree on the path — so a rename job finishing after the
reconciliation inserted fresh, and the stale claim reappeared against
whatever the parent later recreated there. Ordering cannot be recovered
outside the row, because the row is disposable.

So the vacated path is now recorded by the request, which is inside its own
commit and whose row shares the counter's lifetime. A deploy that hands its
metadata to a dependency job — every flow and app, and any script needing a
lock — calls `tally_rename_vacated_path` once its transaction has committed;
scripts reach it through the post-commit hook they already had, which grew a
second variant rather than new plumbing.

That lets the whole deferred apparatus go: `TallyEvidence`, the origin job
arg and its round trip. `deploy_origin::current` is `Some` only inside a
request scope again, and `handle_deployment_metadata` hands `renamed_from`
to the tally only when it can answer for it — git-sync still gets it either
way, so the rename keeps naming itself in the commit message.

The vacated path's kind now reads `delete` rather than `rename_from` for
these deploys, since it is probed rather than declared. The merge treats the
two alike; only the row's tooltip is less specific.

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

* fix(fork): cover raw-app renames, and stop firing CI before the lock exists

Two things the vacated-path call broke or missed:

- `create_script` reads its third return value as "no lock generation
  needed" to decide whether the script is runnable now, and the new
  `VacatedPath` variant made that true for renames that do generate. Those
  fired dependent CI tests from the API against a version with no lockfile,
  and again from the dependency job. The variant now decides it explicitly.
- Raw apps rename through `update_app_raw`, a separate route into
  `update_app_internal`, which the new call had not been attached to. Both
  routes now go through one helper.

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

* test(fork): assert the kind only an inline rename can record

`rename_from` is what a deploy says when it knows it moved the item, which
only the path that reports both halves from its own request can. Nothing
pinned it, and that is the side the vacated-path change touched.

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

* chore: update ee-repo-ref to a45bec03922d305aad5893ed354dc029c7f97bb4

This commit updates the EE repository reference after PR #709 was merged in windmill-ee-private.

Previous ee-repo-ref: 62f494b2a51de0dfc0cfa0c3530ff19a1d32667c

New ee-repo-ref: a45bec03922d305aad5893ed354dc029c7f97bb4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-04 00:50:27 +02:00
Ruben FiszelandClaude Opus 5 689f5d7c75 fix: stop reading a parent-only fork item as deleted in the fork (#10467)
* fix: stop reading a parent-only fork item as deleted in the fork

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

* style: condense the deploy-direction helper comments

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

* fix: keep the ambiguous half of a one-sided diff out of bulk defaults

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

* fix: disable select-all on a removal-only list and cover the hidden source-only row

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

* fix: keep parent-only items out of the fork merge list entirely

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

* fix: count the fork banner's ahead/behind with the compare page's predicate

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

* fix: open the direction the fork banner's button offers

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

* chore: cache the sqlx query for the source-only visibility test

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

* fix: don't read an unloaded comparison as nothing to deploy

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

* fix: treat an in-flight comparison as unknown in the fork banner

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:14:41 +00:00