mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* feat(datatables): put a data table's connection under Postgres roles
A data table backed by the instance database resolved to exactly one Postgres connection,
`custom_instance_user`, for everyone who could reach it at all. There was no way to say
this job reads, that one writes, this one never sees the salaries table.
A data table role is now a real Postgres login on the cluster, defined once for the
instance by a superadmin and named exactly as they named it. A script that declares
`-- role analytics` connects as `analytics`, and Postgres decides what it may touch —
grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from
the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`.
A data table with no `permissions` block behaves exactly as before.
Everything that opens a connection on someone's behalf goes through one chokepoint,
`get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when
there is none. The role logs in as itself — never `SET ROLE`, which a script could
`RESET ROLE` its way out of.
A fork's data table entry becomes a pointer at the workspace that governs it rather than a
copy of it. The settings clone used to hand a fork a byte-identical entry naming the
parent's database, which a fork admin could edit to grant themselves `admin` there; a
pointer has nothing local to edit, and its tenants are evaluated as a member of the
governing workspace, by email. `permissions` is stripped from the workspace export and
ignored on import: tenants name principals of one workspace, and a settings push is not
where an access decision should be made.
Operations that see the whole database whatever the roles grant stay with the governing
workspace's admins: editing the roles, a migration that declares none, and opening a
replication stream for a Postgres trigger or capture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): gate the paths that reach a whole database as admin
Auditing what still resolved through the unchecked resolver turned up three that act for a
caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema
export, the full-schema read, database creation, import and the forked-database drop), the
connection test, and the schema snapshot a fork clone takes of its parent. On a data table
under roles each let any workspace member — or a fork admin who is nobody in the governing
workspace — read or copy the whole database whatever its roles grant.
All three now require admin reach on the governing workspace. A dump taken under a
restricted role would be a silently truncated copy rather than an error, so refusing is the
only right answer for the copy paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping
A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin
putting a *resource-backed* data table under roles, at which point the executor dialled the
host that resource names — one the admin chose — with the role's real cluster password, and
`CONNECT` is granted to every registered instance database. Both ends now refuse: the
permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials
on a non-instance entry rather than trusting the record it read.
Two more places reached the governing database without answering to it. The initial-migration
generator returned a `pg_dump` of the whole schema to any member. And the migration
rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or
removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after
which the parent re-runs every migration from zero. The remote half is now skipped when the
entry resolves into another workspace, which is also just correct: a fork renaming what it
calls a data table changes nothing about the data table.
Also: revoking a tenant now bounces the replication streams of every workspace holding an
entry that resolves here, not only the governing one, so a fork's trigger stops rather than
living on inside its open connection; the instance role catalog and the governing workspace's
tenant lists are no longer returned to someone who cannot edit them; and the tenant rename
dedup collapses non-adjacent duplicates, per role rather than once any role changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): fail loudly where a role or a pointer can be left half-recorded
Three ways the feature could end up in a state nobody could see or undo.
Creating a role writes the cluster first and the catalog second, but the catalog write was an
`UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a
live login with a password nobody recorded: invisible to the catalog, un-recreatable because
the name is taken, and un-deletable because there is no entry to delete. It now errors, so
the operation is retryable once the row is restored.
Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are
left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy
would hand each fork the database outright — so the delete now names the data tables it
stranded, and resolving one says which workspace is missing rather than reporting a data table
this workspace never had.
`InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now
hand-written so `{:?}` on the catalog cannot put a live credential in a log line.
Adds the two branches the reviews found unpinned: a caller who is not a member of the
governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that
predates this and sends no job id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): unbreak two operator messages and two comments that described other code
The two strings this branch added for states an operator hits once — the catalog write that
matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line
form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence.
`list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop
it, and the comment now says why that is the right place to stay quiet. The non-superadmin
check in `edit_datatable_config` was introduced as also covering references, which it does not
and need not: `reference` is overwritten from the stored entry for every caller before the
check runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): serialize role catalog mutations, and state each helper's authorization contract
The catalog is one JSON document, so create, rename, enable and delete are all
read-modify-write. Two concurrent creates read the same snapshot, both succeed in the
cluster, and the second write drops the first — leaving a live Postgres login with a password
nobody recorded, which is the exact state the delete path exists to prevent. Every mutation
now runs in one transaction holding an advisory lock across the read, the cluster DDL and the
write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL
helpers take that transaction rather than the pool, which is what makes the lock cover them.
Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely
multi-statement SQL, and its future is not `Send`, which an axum handler holding the
transaction requires. Each of these is one statement anyway.
The new cross-crate surface now says what callers must do. `read_role_catalog` returns
plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and
`converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's
raw config. All of them are superadmin-gated by their current handlers, but nothing said so at
the definition, which is where the next caller looks.
Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip
that did not land; the rename affordance is the design-system `Button`, not a raw one; and
`resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing
resolves as the data table's default until the database manager grows a picker.
Why role passwords stay a plain `String` while the instance user's password beside them is a
`StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an
operator supplies it and may want it from their own backend, while these are minted here and
never entered by anyone, so there is nothing for a ref to point at. Encrypting generated
secrets at rest is a separate change that would take the replication password with it. Now
said at the field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): give the role catalog its own row, out of reach of the config machinery
Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways.
The catalog serializes a generated Postgres password per role, and that row is the
operator-facing instance config, so the passwords reached `get_instance_config` and its YAML
editor — a live cluster credential in a response body, a UI field and any log of either.
Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of
that key writes the row back without it and the catalog is gone, while the cluster keeps every
login it described.
`custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret,
written only by the server, never operator-authored, hidden so the config machinery cannot
read, rewrite or drop it. The catalog is the same thing, so it now has the same shape:
`datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist.
No redaction to keep in step with three code paths, and no way for a neighbouring write to
take it out.
Two races on the same shared documents. `edit_datatable_config` read the stored data tables
outside its transaction and then wrote the whole `datatable` document, so a permissions save
committing in between was silently rolled back; it now reads under `FOR UPDATE`. And
`set_datatable_permissions` validated role ids against the catalog before opening its
transaction, so a deletion in between let it write a deleted role back — including as the
default, which every later job then fails on; it now holds the catalog lock and the settings
row across validation and write.
Completes the authorization contracts the previous commit claimed but did not finish:
`read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole
job is to answer for a workspace the caller may not belong to, and
`converge_connect_grants_with`, which had not inherited its wrapper's.
Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but
`extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so
code written from that page passed a keyword-only argument positionally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced
The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all
three handlers left it green — it pinned Postgres, not the code it was written for. It now
drives `create_datatable_role` twice concurrently and asserts the catalog kept both names.
Checked the way the last one should have been: removing the lock from the handler makes it
fail with "wmtest_a_… is a live cluster login the catalog forgot".
The contracts added last commit were stricter than this PR's own callers, which is worse than
none — the next reader sees a rule already broken and learns to ignore it.
`read_role_catalog` said superadmin-only while two of its four callers are open to any
workspace member, and `converge_connect_grants` said superadmin while
`set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the
rule that actually holds is about the credential never reaching a response, log, audit record
or export, not about who may call. They now say that. `read_datatable_entry` gets the same
treatment rather than the one the earlier message claimed for it: it is the primitive every
resolution goes through, so it is deliberately open, and what must not escape is `permissions`
— it names the governing workspace's users, groups and folders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): close the last ways a role or a pointer can be left pointing at nothing
The raw settings readers hand back whatever is in the row, so moving the catalog into its own
`global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles`
and the settings listing returning every live password. Both now filter that one key. The
neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it
predates this and widening the fix to it is a decision about an operator workflow, not a
consequence of this change.
Three ways a save could leave something resolving to nothing:
A permissioned data table could be moved to a PostgreSQL resource. The block was carried across
as a server-owned field, the runtime refuses roles on a resource-backed table, so the save
succeeded and every job afterwards failed. Refused instead — turning roles off first is one step,
and it keeps discarding an access decision something somebody chose.
Renaming a governing data table left every fork pointing at the old name: the data table
disappears from their pickers and their jobs stop, with nothing in the renaming workspace to
suggest why. The rename now follows into the pointers in the same transaction.
Deleting one cannot be followed the same way, so it is reported instead — the response names what
it stranded, the way deleting a workspace does, and the fork's own error already says which
workspace is gone.
Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions
handler reaches it as a workspace admin (the same class fixed last commit, one instance missed);
the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog`
was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its
roles only on success — a failing run is exactly the one that creates them without recording them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* refactor(datatables): put the role catalog in its own table, not in global_settings
Five findings across three rounds were all the same choice. A set of live Postgres credentials
was living in `global_settings`, which has generic read, list, write, config-export and CLI
round-trip paths that know nothing about what they carry: the passwords reached the instance
config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog,
`GET /settings/global/{key}` and the settings listing returned them raw, and this round the
redaction that fixed the last two turned `wmill instance push` into something that wipes every
password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles`
could also empty it outside the lock.
The approved plan offered a table or `global_settings`, so this is the other option it already
allowed rather than a new design. `datatable_role` is a table: no generic settings path can read
it, list it, export it, write it or round-trip it, so none of the five needs a guard. The
redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it.
One row per role also removes the read-modify-write the concurrency work was about: two
concurrent creates are two inserts, and the unique index on `name` is what settles a collision.
The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to
another transaction until commit, so without it both creates pass their `pg_roles` check.
Also from this round: rename mappings are checked against the configuration they claim to
describe, since fork pointers are rewritten from them — a caller could otherwise submit
`main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing
has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning
naming forks a delete stranded reached the response but not the screen: both the data table
settings save and the workspace delete now show it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): validate a rename against the save it describes, and re-check under the locks
Three from the round, all about deciding on state that could already have moved.
A permission save resolved the data table and checked it was instance-backed before taking any
lock, then wrote under one. A config save committing in between could move the table onto a
PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in
which case the write targeted a key that no longer existed and reported success having changed
nothing. It now re-resolves and re-checks on the locked state.
Rename validation checked that the source existed before and the target existed after, which
still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows
onto a different data table, silently, because it keeps resolving. The rule is now the actual
old-to-new key transition — a source may only survive if another rename took its name, and a
target may only pre-exist if another rename freed it. That also stops two sources sharing one
target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so
a swap cannot be done one save at a time, and refusing it was a regression against main. The
pointer cascade now runs in two passes through a temporary name, the way the migration cascade
one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once
from what it named before the save.
The tenant mutators say what they are for: they write an access decision for any workspace named,
with an arbitrary mutation, and exist for the transaction that frees or renames a principal.
Editing a decision on purpose belongs in the permissions endpoint.
Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of
a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole
document, so a workspace whose pointers name something else is not rewritten to a byte-identical
value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from
the JSON document, one of which would emit `pwd`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically
The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call
`/workspaces/leave` — a different handler in a different crate with the same name — which
deleted the membership and left `u/<username>` in the tenant lists. Leaving and rejoining
therefore restored the access the leave was supposed to end, and a later account taking the
username would have inherited it. The regression test drives the route the client actually
calls; without the fix it fails with "leaving kept the tenant".
The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's
admin connection, created `_wm_migrations` and read it before reaching the per-migration role
check — so with nothing pending, nothing was checked at all. Rollback returned before its check
when nothing was applied, and the status endpoint had none. All three now ask, before any
connection is opened, whether the caller can reach the data table as any role at all; which role
a given migration runs as is still decided per migration, and by the executor after that.
Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in
separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect
as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now
runs in the same transaction, so the drop, the row and every tenant list commit together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): refuse to copy a data table that is under roles
pg_dump carries no roles and the import runs with --no-privileges, so a copied
data table arrives owned by the admin connection with no GRANT for any role.
The settings clone brings `permissions` across, so the fork's tenants pass
Windmill's check, connect as the role they were given, and are denied by
Postgres on everything: an entry that reads as configured and answers nothing.
Refuse the copy — in the import endpoint before any data moves, and in the fork
path the CLI takes. Replaying the source's owners and ACLs into the clone is
what lifts this, and is a change of its own. Dropping `permissions` from the
copy instead would be the unsafe half, since the copy holds the parent's rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): refuse the clone's database too, not only its data
A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only
the second refused a data table under roles, so a fork asking to clone one
created and registered an empty `wm_fork_…` instance database and then failed —
and nothing collects it, since `drop_forked_datatable_databases` only drops
entries carrying `forked_from` and no entry names this one.
Refuse in both, so the clone stops before a database exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* nit worker error msg
* fix pg_dump stuck on version 17 on nix
* fix(datatables): refuse a malformed role annotation instead of ignoring it
`-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed
the annotation parser's exact-match rule, so the query fell through to the data
table's default role and ran, silently, under a login the author did not choose.
Naming a role exists precisely to not do that.
A leading comment whose first word is `role` is now an annotation attempt: the
keyword matches case-insensitively, one trailing `;` is tolerated, and anything
else is an error naming the line. Only callers that already know the target is a
`datatable://` reference ever run this, so ordinary SQL keeps its comments.
Also bumps the dev shell's postgres client to 18 — it trailed the server the dev
database runs, which takes out every data table export, clone and fork-with-data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): refuse a malformed role query string instead of ignoring it
`?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference
parser's exact-match rule, so the connection resolved to the data table's default
role and ran under a login the caller never asked for — the URI half of the same
trap as a malformed `-- role` annotation.
The key now matches case-insensitively, and anything else in the query string is
an error naming it; `role` is the only parameter a reference takes. Callers that
only need the entry keep a lenient `datatable_ref_name`, since they never act on
the role. The DuckDB `ATTACH` parser propagates it rather than attaching under
the default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): carry the role annotation into the row_to_json retry
The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment
block never reached the second attempt — and with it the `-- role <name>` line
that decides which login the query runs as. The retry connected as the data
table's default role instead, so a query the first attempt was denied could
succeed on the second, reported as "recovered with the row_to_json fix".
Carry the leading comment block over. The retry itself is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* chore(datatables): don't mount the roles UI until the ACL editor lands
Enforcement ships first. The permissions drawer is what turns roles on, and the
catalog section is what creates them — both are only useful once there is a way
to grant a role the privileges it needs, which arrives with the ACL editor. Left
mounted they would offer a feature whose other half does not exist.
The two components are complete and reviewed; only their call sites here are
commented out, with a note pointing the follow-up PRs at them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): honour `-- role: x`, and fix the DuckDB attach test
Two review findings, both real.
`attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable`
returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its
`?Role=analytics` case also asserted a refusal, contradicting the parser in the
same commit, which matches the key case-insensitively. Replaced with the cases
that are genuinely malformed, and a positive one for the cased key.
`-- role: analytics` fell through to the default role — the silent fallback the
strict parser exists to remove, for the spelling most likely to be typed. The
keyword now accepts an optional colon, attached or spaced, while a word that
merely starts with it (`rolebased`) is still not an attempt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): clone a fork's pointer instead of failing after the copy
Forking a fork with cloning left an orphan database. The preflight resolves the
pointer and sees the governing entry, so both endpoints ran and filled the new
database; `apply_forked_datatable` then refused the inherited pointer and rolled
the fork back, stranding a registered `wm_fork_*` that no entry names and whose
name blocks the retry.
Refusing earlier would have been the smaller change, but forking a fork and
cloning worked before pointers existed, so it would trade an orphan for a
regression. Resolve what the pointer names and write the terminal entry the
clone needs: the whole `database` object rather than a patch of its
`resource_path`, since a pointer has none, and `reference` removed with it.
Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through
to the default role.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): refuse to roll back the catalog while roles exist
The down migration dropped the table and left every role behind: live Postgres
logins whose passwords only that table carried, so after a revert Windmill could
neither use, disable nor delete them, and re-applying could not recreate them
because the names were taken. Cleaning up here is not possible either — dropping
a role means reassigning what it owns in every instance database, and a
migration runs in one — so it now refuses while the catalog is non-empty and
says to delete the roles through instance settings, which does the cluster work.
Also enforces the instance-only invariant the resolved-pointer clone relies on
rather than only asserting it in a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* refactor(datatables): settle clonability in one place, before anything is created
A clone is three stages a workspace apart — `create_pg_database`, then
`import_pg_database`, then `apply_forked_datatable` inside the fork transaction.
Only the third can roll back, and `CREATE DATABASE` is not transactional, so any
refusal that lives there strands a registered `wm_fork_*` that no entry names
and whose name blocks the retry.
That orphan has now been fixed three times, most recently reintroduced by a
guard added one commit ago. Patching each new refusal into the first endpoint is
not the fix; having two places that can refuse is. `ensure_datatable_is_clonable`
now answers every reason a copy can be refused and returns what it resolved, and
the stage that writes the entry only does the work.
Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role
created concurrently cannot slip between the check and the drop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): let a retried clone reclaim its own leftover database
A clone creates its target database one request before it copies into it, and
the fork that would name it is written a request after that. Any failure in
between — a pg_dump error, a bad restore, a dropped connection, the source's
roles changing mid-flow — left a registered `wm_fork_*` that no entry names,
and every retry then failed on its name. This predates data table roles.
`create_pg_database` now reclaims such a leftover before creating: only a
`wm_fork_*` database Windmill registered as a data table database and that no
data table or ducklake entry names, in any workspace, archived ones included.
The drop never terminates connections, so a clone still copying into it makes
the reclaim fail instead of being cut off. It is limited to callers who
administer the source — reaching it is not enough, since on a data table
without roles every member reaches it — and anyone else gets the refusal an
existing database always got.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Revert "fix(datatables): let a retried clone reclaim its own leftover database"
This reverts commit 7dd3275a10.
The reclaim tied the caller to the source they administer, but not to the
database it dropped. Between another workspace's import and its final fork
request, that workspace's target is full, registered, unnamed and has no open
connection, so an admin of any instance data table could name it and have it
dropped and recreated empty. The victim's fork would then commit pointing at
the empty copy. Safe reclaim needs durable clone ownership and serialization
with the request that names the database; until then the leftover stays, as it
did before this PR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(datatables): record the stale clone database as a known limitation
A clone is three requests and `CREATE DATABASE` is not transactional, so a
failure after the first leaves a registered `wm_fork_*` behind, as it did
before data table roles. Accepted for this PR: it is harmless to data and goes
away once the clone is a single server-side operation.
The comment also records why the obvious fix is wrong: reclaiming the leftover
on retry, without durable clone ownership, can drop another workspace's fully
copied database between its import and its final fork request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): bounce the streams reading a data table when it is deleted
Deleting a governing data table, or the workspace that holds it, only collected
the fork pointers it stranded, for the warning. A Postgres trigger or capture
already streaming through one of those pointers kept the replication connection
it opened while the pointer still resolved, so it went on dispatching the
governing database's rows after the fork lost access — until its connection
happened to restart. The governing workspace's own streams on a deleted entry
did the same.
Both deletion paths now bounce the affected listeners inside their own
transaction, through the helper a permission change already uses, so a
listener that reconnects re-resolves the entry and finds it gone. The helper is
split so a caller can pass the (workspace, local name) pairs it already holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): keep the fork schema baseline, and bounce streams on every removal
Three fixes from review.
`edit_datatable_config` took `forked_from` wholesale from the stored entry, so
the fork schema diff's save of an advanced baseline was silently discarded and
an applied change was offered again. Whether an entry carries a clone stamp is
still carried from the store, since that is what marks its database droppable,
but the baseline inside it is now taken from the request.
The stranded-pointer warning and the stream bounce ran over the optional
`deleted_datatables` hint, which the settings-sync CLI never sends, so removing
a governing data table through `wmill` bounced nothing. Removals are now derived
from the stored configuration against the saved one.
`delete_workspace` read the pointers to bounce before its transaction, so a fork
committing a pointer during the deletion was missed. The read now happens inside
the transaction, after the workspace row is deleted: a fork's insert key-share
locks that row through its parent foreign key, so it is either seen or fails on
the missing parent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(datatables): keep Postgres triggers and data table roles apart
A replication stream reads every row of every table whatever the data table's
roles grant, and its listener checks access only when it connects. Rather than
chase every way access can change and bounce the streams each one affects, a
data table now carries one or the other:
- a Postgres trigger or capture cannot be created on, or connect to, a data
table under roles;
- roles cannot be turned on while an enabled trigger or a live capture reads
the data table, its own or a fork's through its pointer. The refusal names
each one to disable.
This removes the stream bounces on roles edits and on data table and workspace
deletion, and the trigger gate that admitted admins. The fork schema baseline
fix from the same review round is kept.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved
Creating or editing a trigger that points at a data table under roles was
accepted, and its listener then retried the refused connection every 30
seconds forever. The save is now refused, and a trigger that reaches such a
data table anyway (re-enabled, or cloned into a fork) is disabled by its
listener with the reason, as a missing replication slot is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): disable a data table role before deleting it
Deleting a role reassigns and drops what it owns in each registered
database on its own connection, and each of those passes commits as it
goes. A database failing part-way left the role enabled in the catalog and
able to log in, but already stripped in the databases reached before it.
The role is now disabled in its own commit first, so a failed delete
leaves a disabled role to retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): serialize roles going on with a stream starting
Turning roles on looked for enabled triggers and live captures once,
without a lock anything starting a stream also took. A trigger enabled in
that window could have its listener connect before roles committed, and a
healthy listener never checks again. Both transitions now serialize on one
advisory lock: roles going on hold it exclusive while they look, and
trigger create, edit and enable, and capture setup and ping hold it shared
while they commit. Either the look sees the stream, or the listener
connects after roles are committed and refuses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): wait out live listeners, and resolve stored names containing `?`
Turning roles on counted a trigger as gone once disabled, and a capture
once its client stopped pinging, but the listener keeps its replication
connection until its next heartbeat notices. A trigger or capture whose
listener pinged in the last 15 seconds, the window a server holds a
listener for, now still counts as streaming.
Data table names could contain `?` before they were restricted, and such
entries are still stored. Splitting `?role=` off a reference misread them:
`a?b` became `a` with an unknown parameter, and the clone checks looked at
a different entry than the one copied. An entry stored under the whole
reference is now looked up first, in the Postgres executor, DuckDB ATTACH
and the clone checks. Agent workers cannot read the workspace and keep
the strict parse, which refuses such a name rather than misreading it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): warn when a settings sync strands fork pointers
A settings save reported the fork pointers left resolving to nothing only
for the names in `deleted_datatables`, which `wmill sync push` never sends.
The save now works out what it removed from the locked entries, and the
CLI prints the stranded pointers it returns.
Also correct the replication helper's contract: no role or admin check
makes a replication connection safe, so a data table under roles is
refused outright rather than gated as an admin operation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): refuse a save that drops a data table's roles through an undeclared rename
A data table's roles follow its entry only through a declared rename. A
settings sync sends the whole map and never declares one, so renaming a
data table under roles there read as a delete and a new entry on the same
database: the new entry carried no roles, and every caller connected as
admin. Such a save is now refused, naming both entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): no entry without roles may newly reach a database under roles
The previous guard only caught a new name replacing an entry under roles.
A whole-map save could also repoint an existing entry without roles at
that database, or another workspace could point one there, and every
caller of that entry would connect as admin. The rule is now stated on
the saved entries: one that carries no roles and newly points at an
instance database any entry under roles uses, in this workspace or
another, is refused. A declared rename carries its roles and passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* feat(datatables): move data table role catalog and resolution to the enterprise edition
Roles are an Enterprise Edition feature. The catalog, the Postgres logins,
CONNECT convergence, tenant evaluation and the role half of connection
resolution move to windmill-ee-private. Every public function keeps its path
and signature and forwards through datatable_roles_oss, which re-exports the
enterprise implementation or, without it, refuses.
Without the enterprise edition a data table under roles, or a caller naming a
role, is refused a connection rather than resolved as admin, and the reach and
admin-access checks refuse one under roles. A data table not under roles
resolves as before in every edition, and an instance database keeps the
CONNECT grants it was created with. The catalog lock, the stream lock, the
tenant cascades and the permissions stripping stay in OSS: they only restrict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* feat(datatables): move the data table permissions endpoints to the enterprise edition
The permissions read, save and usable-roles handlers move to
windmill-ee-private; the routes stay registered and, without the enterprise
edition, answer that data table roles are an Enterprise Edition feature.
ensure_governs_datatable and ensure_reaches_datatable keep their paths: the
first refuses, the second passes a data table not under roles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* feat(datatables): move the data table role catalog endpoints to the enterprise edition
The superadmin list, create, update and delete handlers move to
windmill-ee-private. The routes stay registered and, without the enterprise
edition, refuse after authentication.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* test(datatables): run the roles tests on the enterprise edition, refusals without it
Each test that exercises roles runs with private and enterprise. Two tests run
without them: every roles route answers the Enterprise refusal, and a data
table saved under roles, or a named role, is refused a connection while one
not under roles resolves as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* feat(datatables): gate the roles UI mount sites on an enterprise license
Both mount sites are still commented out; the gate travels with them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* test(datatables): run the tenant matcher test on the enterprise edition
The matcher it covers is enterprise code now, so without the enterprise
edition the test hit the stub and failed the default windmill-common run. It
runs with private and enterprise, and a counterpart without them asserts that
no tenant list covers anyone, the wildcard and a workspace admin included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607
This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private.
Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4
New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607
Automated by sync-ee-ref workflow.
* fix(datatables): refuse roles while a same-workspace alias reaches the database
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): let CE migrations connect as an explicitly named admin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): serialize roles going on with aliases saved from other workspaces
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(datatables): note that legacy names with ? cannot be migrated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): drop a DuckDB data table secret once its ATTACH has used it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(datatables): resolve a workspace's data tables per pointer hop, not per entry
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(datatables): hold the parent's settings while a fork points at its data tables
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 7e338e4dabf91689bfd7fb0333c6534040b17b59
This commit updates the EE repository reference after PR #787 was merged in windmill-ee-private.
Previous ee-repo-ref: 38d6fcf2aeb39cfdac21814bbdbbcc02911e566a
New ee-repo-ref: 7e338e4dabf91689bfd7fb0333c6534040b17b59
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>
13301 lines
485 KiB
Rust
13301 lines
485 KiB
Rust
/*
|
||
* Author: Ruben Fiszel
|
||
* Copyright: Windmill Labs, Inc 2022
|
||
* This file and its contents are licensed under the AGPLv3 License.
|
||
* Please see the included NOTICE for copyright information and
|
||
* LICENSE-AGPL for a copy of the license.
|
||
*/
|
||
|
||
use windmill_api_auth::{
|
||
build_scope_path_predicate, check_scopes, require_devops_role, require_instance_admin,
|
||
require_is_writer, require_super_admin, ApiAuthed,
|
||
};
|
||
use windmill_api_users::users::WorkspaceInvite;
|
||
use windmill_common::email_oss::send_email_if_possible;
|
||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||
use windmill_common::webhook::WebhookShared;
|
||
use windmill_common::{BASE_URL, DB};
|
||
use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace;
|
||
|
||
use axum::{
|
||
extract::{Extension, Path, Query},
|
||
routing::{delete, get, post},
|
||
Json, Router,
|
||
};
|
||
use chrono::Utc;
|
||
|
||
use regex::Regex;
|
||
|
||
use hex;
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::{HashMap, HashSet};
|
||
use strum::IntoEnumIterator;
|
||
use uuid::Uuid;
|
||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||
use windmill_audit::ActionKind;
|
||
use windmill_common::db::UserDB;
|
||
use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
|
||
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
|
||
use windmill_common::users::username_to_permissioned_as;
|
||
use windmill_common::variables::{
|
||
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
|
||
};
|
||
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
|
||
use windmill_common::workspaces::GitRepositorySettings;
|
||
#[cfg(feature = "enterprise")]
|
||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||
use windmill_common::workspaces::{
|
||
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db,
|
||
get_datatable_resource_from_db_unchecked, parse_datatable_ref_for, resolve_governing_datatable,
|
||
validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable,
|
||
DataTableCatalogResourceType, DataTableForkBehavior, DatatableAccess, GoverningDatatable,
|
||
ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult,
|
||
WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||
};
|
||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||
use windmill_common::PgDatabase;
|
||
use windmill_common::{
|
||
error::{pg_error_message, Error, JsonResult, Result},
|
||
global_settings::{
|
||
AUTOMATE_USERNAME_CREATION_SETTING, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING,
|
||
},
|
||
oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH,
|
||
utils::{paginate, rd_string, require_admin, Pagination},
|
||
};
|
||
use windmill_dep_map::scoped_dependency_map::{
|
||
DependencyDependent, DependencyMap, ScopedDependencyMap,
|
||
};
|
||
use windmill_git_sync::{
|
||
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
|
||
DeployedObject,
|
||
};
|
||
use windmill_types::s3::LargeFileStorage;
|
||
|
||
use hyper::StatusCode;
|
||
use serde::{Deserialize, Serialize};
|
||
use sqlx::{FromRow, Postgres, Row, Transaction};
|
||
use windmill_common::oauth2::InstanceEvent;
|
||
use windmill_common::secret_backend::{get_secret_backend, is_vault_backend_configured};
|
||
use windmill_common::utils::not_found_if_none;
|
||
|
||
lazy_static::lazy_static! {
|
||
static ref WORKSPACE_KEY_REGEXP: Regex = Regex::new("^[a-zA-Z0-9]{64}$").unwrap();
|
||
}
|
||
|
||
pub fn workspaced_service() -> Router {
|
||
Router::new()
|
||
.route("/get_as_superadmin", get(get_workspace_as_superadmin))
|
||
.route("/list_pending_invites", get(list_pending_invites))
|
||
.route("/update", post(edit_workspace))
|
||
.route("/archive", post(archive_workspace))
|
||
.route("/invite_user", post(invite_user))
|
||
.route("/add_user", post(add_user))
|
||
.route("/create_service_account", post(create_service_account))
|
||
.route("/delete_invite", post(delete_invite))
|
||
.route("/rebuild_dependency_map", post(rebuild_dependency_map))
|
||
.route("/get_dependency_map", get(get_dependency_map))
|
||
.route("/get_dependents/{*imported_path}", get(get_dependents))
|
||
.route("/get_imports/{*importer_path}", get(get_imports))
|
||
.route("/get_dependents_amounts", post(get_dependents_amounts))
|
||
.route("/get_settings", get(get_settings))
|
||
.route("/get_public_settings", get(get_public_settings))
|
||
.route(
|
||
"/get_copilot_settings_state",
|
||
get(get_copilot_settings_state),
|
||
)
|
||
.route("/get_deploy_to", get(get_deploy_to))
|
||
.route("/edit_slack_command", post(edit_slack_command))
|
||
.route(
|
||
"/run_slack_message_test_job",
|
||
post(run_slack_message_test_job),
|
||
)
|
||
.route("/slack_oauth_config", get(get_slack_oauth_config))
|
||
.route("/slack_oauth_config", post(set_slack_oauth_config))
|
||
.route("/slack_oauth_config", delete(delete_slack_oauth_config))
|
||
.route("/edit_webhook", post(edit_webhook))
|
||
.route("/edit_auto_invite", post(edit_auto_invite))
|
||
.route("/edit_instance_groups", post(edit_instance_groups))
|
||
.route(
|
||
"/get_secondary_storage_names",
|
||
get(get_secondary_storage_names),
|
||
)
|
||
.route("/is_premium", get(is_premium))
|
||
.route("/billable_seats", get(get_billable_seats))
|
||
.route("/edit_error_handler", post(edit_error_handler))
|
||
.route("/edit_success_handler", post(edit_success_handler))
|
||
.route(
|
||
"/edit_large_file_storage_config",
|
||
post(edit_large_file_storage_config),
|
||
)
|
||
.route("/edit_dbt_warehouses", post(edit_dbt_warehouses))
|
||
.route("/edit_ducklake_config", post(edit_ducklake_config))
|
||
.route("/list_ducklakes", get(list_ducklakes))
|
||
.route("/list_datatables", get(list_datatables))
|
||
.route("/list_datatable_schemas", get(list_datatable_schemas))
|
||
.route("/list_datatable_tables", get(list_datatable_tables))
|
||
.route(
|
||
"/get_datatable_table_schema",
|
||
get(get_datatable_table_schema),
|
||
)
|
||
.route("/edit_datatable_config", post(edit_datatable_config))
|
||
.route(
|
||
"/test_datatable_connection/{datatable_name}",
|
||
get(test_datatable_connection),
|
||
)
|
||
.merge(crate::datatable_migrations::routes())
|
||
.merge(crate::datatable_permissions::routes())
|
||
.route("/git_sync_enabled", get(get_git_sync_enabled))
|
||
.route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode))
|
||
.route("/edit_git_sync_config", post(edit_git_sync_config))
|
||
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
|
||
.route(
|
||
"/delete_git_sync_repository",
|
||
delete(delete_git_sync_repository),
|
||
)
|
||
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
|
||
.route("/edit_default_app", post(edit_default_app))
|
||
.route("/edit_guest_access", post(edit_guest_access))
|
||
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
|
||
.route("/guest_usage", get(get_guest_usage))
|
||
.route("/default_app", get(get_default_app))
|
||
.route(
|
||
"/default_scripts",
|
||
post(edit_default_scripts).get(get_default_scripts),
|
||
)
|
||
.route("/set_environment_variable", post(set_environment_variable))
|
||
.route(
|
||
"/encryption_key",
|
||
get(get_encryption_key).post(set_encryption_key),
|
||
)
|
||
.route("/leave", post(leave_workspace))
|
||
.route("/get_workspace_name", get(get_workspace_name))
|
||
.route("/create_fork", post(create_workspace_fork))
|
||
.route("/attach_dev_workspace", post(attach_dev_workspace))
|
||
.route("/detach_dev_workspace", post(detach_dev_workspace))
|
||
.route("/get_dev_workspace", get(get_dev_workspace))
|
||
.route("/change_workspace_name", post(change_workspace_name))
|
||
.route("/change_workspace_color", post(change_workspace_color))
|
||
.route(
|
||
"/change_workspace_id",
|
||
post(crate::workspaces_extra::change_workspace_id),
|
||
)
|
||
.route("/usage", get(get_usage))
|
||
.route("/used_triggers", get(get_used_triggers))
|
||
.route("/public_app_rate_limit", post(edit_public_app_rate_limit))
|
||
.route("/operator_settings", post(update_operator_settings))
|
||
.route(
|
||
"/create_workspace_fork_branch",
|
||
post(create_workspace_fork_branch),
|
||
)
|
||
.route(
|
||
"/reset_diff_tally/{fork_workspace_id}",
|
||
post(reset_workspace_diffs),
|
||
)
|
||
.route("/compare/{target_workspace_id}", get(compare_workspaces))
|
||
.route(
|
||
"/seed_full_diff/{target_workspace_id}",
|
||
post(seed_full_diff_scan),
|
||
)
|
||
.route("/create_pg_database", post(create_pg_database))
|
||
.route("/import_pg_database", post(import_pg_database))
|
||
.route("/export_pg_schema", post(export_pg_schema))
|
||
.route(
|
||
"/drop_forked_datatable_databases",
|
||
post(crate::workspaces_extra::drop_forked_datatable_databases),
|
||
)
|
||
.route(
|
||
"/drop_forked_ducklake_namespaces",
|
||
post(crate::workspaces_extra::drop_forked_ducklake_namespaces),
|
||
)
|
||
.route(
|
||
"/get_datatable_full_schema",
|
||
post(get_datatable_full_schema),
|
||
)
|
||
.route("/protection_rules", get(list_protection_rules))
|
||
.route("/protection_rules", post(create_protection_rule))
|
||
.route(
|
||
"/protection_rules/{rule_name}",
|
||
post(update_protection_rule).delete(delete_protection_rule),
|
||
)
|
||
.route("/log_feature_usage", post(log_feature_usage))
|
||
.route("/cloud_quotas", get(get_cloud_quotas))
|
||
.route("/prune_versions", post(prune_versions))
|
||
.route("/list_ws_specific", get(list_ws_specific))
|
||
.route("/list_ws_specific_versions", get(list_ws_specific_versions))
|
||
.route("/set_ws_specific", post(set_ws_specific))
|
||
}
|
||
pub fn global_service() -> Router {
|
||
Router::new()
|
||
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
|
||
.route("/list", get(list_workspaces))
|
||
.route("/users", get(user_workspaces))
|
||
.route("/session_workspace_status", post(session_workspace_status))
|
||
.route(
|
||
"/session_workspace_retention",
|
||
post(session_workspace_retention),
|
||
)
|
||
.route("/create", post(create_workspace))
|
||
.route("/create_fork", post(deprecated_create_workspace_fork))
|
||
.route("/exists", post(exists_workspace))
|
||
.route("/exists_username", post(exists_username))
|
||
.route("/allowed_domain_auto_invite", get(is_allowed_auto_domain))
|
||
.route("/unarchive/{workspace}", post(unarchive_workspace))
|
||
.route(
|
||
"/delete/{workspace}",
|
||
delete(crate::workspaces_extra::delete_workspace),
|
||
)
|
||
.route(
|
||
"/create_workspace_require_superadmin",
|
||
get(create_workspace_require_superadmin),
|
||
)
|
||
}
|
||
|
||
#[derive(FromRow, Serialize)]
|
||
struct Workspace {
|
||
id: String,
|
||
name: String,
|
||
owner: String,
|
||
deleted: bool,
|
||
premium: bool,
|
||
color: Option<String>,
|
||
parent_workspace_id: Option<String>,
|
||
is_dev_workspace: bool,
|
||
dev_workspace_label: Option<String>,
|
||
}
|
||
|
||
#[derive(FromRow, Serialize, Debug)]
|
||
pub struct WorkspaceSettings {
|
||
pub workspace_id: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_team_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub teams_team_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub teams_team_name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub teams_team_guid: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_command_script: Option<String>,
|
||
pub teams_command_script: Option<String>,
|
||
pub slack_email: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_oauth_client_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_oauth_client_secret: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub customer_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub plan: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub webhook: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub ai_config: Option<serde_json::Value>,
|
||
/// Pointers to the resources dbt scripts run against by default.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub dbt_warehouses: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub large_file_storage: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub ducklake: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub datatable: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub git_sync: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub deploy_ui: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub default_app: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub default_scripts: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub mute_critical_alerts: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub color: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub operator_settings: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub git_app_installations: Option<serde_json::Value>,
|
||
// Grouped config fields
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub auto_invite: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub error_handler: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub success_handler: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||
pub error_handler_fallback_to_instance_alerts: bool,
|
||
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
|
||
/// own `execution_mode: guest` is inert while this is off.
|
||
pub guest_access_enabled: bool,
|
||
/// The key a guest JWT is verified against: a PEM public key, or a JWKS URL, at most
|
||
/// one (a DB CHECK enforces it). Public material, not a secret, so it is admin-
|
||
/// readable here. `None`/`None` falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||
/// off cloud, or accepts no JWT guest if none is set; `guest_access_enabled` is the switch.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub guest_jwt_public_key: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub guest_jwt_jwks_url: Option<String>,
|
||
}
|
||
|
||
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
|
||
/// member. Adding a field here means it will be readable by every authed user
|
||
/// in the workspace — anything sensitive (OAuth secrets, GitHub App tokens,
|
||
/// billing/customer info, integration credentials, etc.) must NOT be added.
|
||
/// The full `WorkspaceSettings` struct is admin-only via `get_settings`.
|
||
#[derive(FromRow, Serialize, Debug)]
|
||
pub struct WorkspacePublicSettings {
|
||
pub workspace_id: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_team_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub slack_name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub teams_team_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub teams_team_name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub teams_team_guid: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub mute_critical_alerts: Option<bool>,
|
||
/// Not sensitive, and the app editor needs it to say whether the guest rung is
|
||
/// live -- an app can be set to `guest` while the workspace has guests off.
|
||
pub guest_access_enabled: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub deploy_ui: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub large_file_storage: Option<serde_json::Value>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub datatable: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||
pub struct CopilotSettingsState {
|
||
pub has_instance_ai_config: bool,
|
||
pub uses_instance_ai_config: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub instance_ai_summary: Option<InstanceAISummary>,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||
pub struct InstanceAIProviderSummary {
|
||
pub provider: String,
|
||
pub models: Vec<String>,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||
pub struct InstanceAIModelSummary {
|
||
pub provider: String,
|
||
pub model: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||
pub struct InstanceAISummary {
|
||
pub providers: Vec<InstanceAIProviderSummary>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub default_model: Option<InstanceAIModelSummary>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub metadata_model: Option<InstanceAIModelSummary>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub code_completion_model: Option<InstanceAIModelSummary>,
|
||
}
|
||
|
||
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
|
||
// pub enum WorkspaceKeyKind {
|
||
// Cloud,
|
||
// }
|
||
|
||
#[derive(Deserialize)]
|
||
struct EditCommandScript {
|
||
slack_command_script: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct RunSlackMessageTestJobRequest {
|
||
hub_script_path: String,
|
||
channel: String,
|
||
test_msg: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct RunSlackMessageTestJobResponse {
|
||
job_uuid: String,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
#[derive(Deserialize)]
|
||
pub struct EditAutoInvite {
|
||
pub operator: Option<bool>,
|
||
pub invite_all: Option<bool>,
|
||
pub auto_add: Option<bool>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct EditWebhook {
|
||
webhook: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize, Serialize, Debug)]
|
||
struct LargeFileStorageWithSecondary {
|
||
#[serde(flatten)]
|
||
large_file_storage: LargeFileStorage,
|
||
#[serde(default)]
|
||
secondary_storage: HashMap<String, LargeFileStorage>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
volume_storage: Option<String>,
|
||
}
|
||
#[derive(Deserialize, Debug)]
|
||
struct EditLargeFileStorageConfig {
|
||
large_file_storage: Option<LargeFileStorageWithSecondary>,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug)]
|
||
struct EditDucklakeConfig {
|
||
settings: DucklakeSettings,
|
||
}
|
||
|
||
#[derive(Deserialize, Serialize, Debug)]
|
||
pub struct DucklakeSettings {
|
||
pub ducklakes: HashMap<String, Ducklake>,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug)]
|
||
struct EditDataTableConfig {
|
||
settings: DataTableSettings,
|
||
// Data table renames (old -> new) and deletions, tracked client-side by a
|
||
// stable id, so we can cascade or drop each data table's migrations.
|
||
#[serde(default)]
|
||
renames: Vec<crate::datatable_migrations::DatatableRename>,
|
||
#[serde(default)]
|
||
deleted_datatables: Vec<String>,
|
||
}
|
||
|
||
#[derive(Deserialize, Serialize, Debug)]
|
||
pub struct DataTableSettings {
|
||
pub datatables: HashMap<String, DataTable>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct CreateWorkspace {
|
||
id: String,
|
||
name: String,
|
||
username: Option<String>,
|
||
color: Option<String>,
|
||
#[serde(default)]
|
||
error_handler_fallback_to_instance_alerts: bool,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct CreateWorkspaceFork {
|
||
id: String,
|
||
name: String,
|
||
color: Option<String>,
|
||
/// Datatable names that were forked. For each, the backend will update the
|
||
/// forked workspace's datatable config to point to the new database.
|
||
#[serde(default)]
|
||
forked_datatables: Vec<ForkedDatatableInfo>,
|
||
/// Lakes the user explicitly chose to SHARE with the parent (the fork then reads and
|
||
/// writes the parent's lake directly). Every lake not listed gets the default isolated
|
||
/// fork namespace + read-defer.
|
||
#[serde(default)]
|
||
shared_ducklakes: Vec<String>,
|
||
/// Create the fork as a persistent dev workspace: the id is not required to carry the
|
||
/// `wm-fork-` prefix, and at most one dev workspace may exist per parent.
|
||
#[serde(default)]
|
||
is_dev_workspace: bool,
|
||
/// When creating a dev workspace, lock the parent ("prod") against direct deployment and/or
|
||
/// ad-hoc forking, so edits are funneled through the dev workspace.
|
||
#[serde(default)]
|
||
lock_prod_deploy: bool,
|
||
#[serde(default)]
|
||
lock_prod_forking: bool,
|
||
/// Copy the parent's members (usr rows + group memberships) into the fork so
|
||
/// the team can work in it. Defaults off; the dev-workspace UI defaults it on.
|
||
#[serde(default)]
|
||
copy_members: bool,
|
||
/// Environment label for the dev workspace, e.g. 'dev' or 'staging': its badge text and the
|
||
/// branch it deploys to. Ignored for non-dev forks. None defaults to 'dev'.
|
||
#[serde(default)]
|
||
dev_workspace_label: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ForkedDatatableInfo {
|
||
name: String,
|
||
new_dbname: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct EditWorkspace {
|
||
name: String,
|
||
owner: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct WorkspaceList {
|
||
pub email: String,
|
||
pub workspaces: Vec<UserWorkspace>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct UserWorkspace {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub username: String,
|
||
pub color: Option<String>,
|
||
pub operator_settings: Option<Option<serde_json::Value>>,
|
||
pub parent_workspace_id: Option<String>,
|
||
pub is_dev_workspace: bool,
|
||
pub dev_workspace_label: Option<String>,
|
||
/// Creator of the workspace (`workspace.owner`). On a fork it identifies the forker, who gets a
|
||
/// narrow membership grant over it even without being an admin — the UI keys the fork members
|
||
/// screen off this.
|
||
pub created_by: Option<String>,
|
||
pub disabled: bool,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct WorkspaceId {
|
||
pub id: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ValidateUsername {
|
||
pub id: String,
|
||
pub username: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct NewWorkspaceInvite {
|
||
pub email: String,
|
||
pub is_admin: bool,
|
||
pub operator: bool,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct NewWorkspaceUser {
|
||
pub email: String,
|
||
pub username: Option<String>,
|
||
pub is_admin: bool,
|
||
pub operator: bool,
|
||
}
|
||
|
||
// New format for error handler (grouped)
|
||
#[derive(Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct EditErrorHandlerNew {
|
||
pub path: Option<String>,
|
||
pub extra_args: Option<serde_json::Value>,
|
||
#[serde(default)]
|
||
pub muted_on_cancel: bool,
|
||
#[serde(default)]
|
||
pub muted_on_user_path: bool,
|
||
/// Left as `None` by clients that predate the setting (the CLI among them), which must
|
||
/// keep the stored value rather than silently reset it on every settings push.
|
||
pub fallback_to_instance_alerts: Option<bool>,
|
||
}
|
||
|
||
// Legacy format for error handler (flat fields from old CLI)
|
||
#[derive(Deserialize)]
|
||
pub struct EditErrorHandlerLegacy {
|
||
pub error_handler: Option<String>,
|
||
pub error_handler_extra_args: Option<serde_json::Value>,
|
||
#[serde(default)]
|
||
pub error_handler_muted_on_cancel: bool,
|
||
}
|
||
|
||
// Accepts both old and new formats
|
||
#[derive(Deserialize)]
|
||
#[serde(untagged)]
|
||
pub enum EditErrorHandler {
|
||
New(EditErrorHandlerNew),
|
||
Legacy(EditErrorHandlerLegacy),
|
||
}
|
||
|
||
impl EditErrorHandler {
|
||
pub fn into_normalized(self) -> EditErrorHandlerNew {
|
||
match self {
|
||
EditErrorHandler::New(new) => new,
|
||
EditErrorHandler::Legacy(legacy) => EditErrorHandlerNew {
|
||
path: legacy.error_handler,
|
||
extra_args: legacy.error_handler_extra_args,
|
||
muted_on_cancel: legacy.error_handler_muted_on_cancel,
|
||
muted_on_user_path: false, // Old format doesn't have this field
|
||
fallback_to_instance_alerts: None,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// New format for success handler (grouped)
|
||
#[derive(Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct EditSuccessHandlerNew {
|
||
pub path: Option<String>,
|
||
pub extra_args: Option<serde_json::Value>,
|
||
}
|
||
|
||
// Legacy format for success handler (flat fields from old CLI)
|
||
#[derive(Deserialize)]
|
||
pub struct EditSuccessHandlerLegacy {
|
||
pub success_handler: Option<String>,
|
||
pub success_handler_extra_args: Option<serde_json::Value>,
|
||
}
|
||
|
||
// Accepts both old and new formats
|
||
#[derive(Deserialize)]
|
||
#[serde(untagged)]
|
||
pub enum EditSuccessHandler {
|
||
New(EditSuccessHandlerNew),
|
||
Legacy(EditSuccessHandlerLegacy),
|
||
}
|
||
|
||
impl EditSuccessHandler {
|
||
pub fn into_normalized(self) -> EditSuccessHandlerNew {
|
||
match self {
|
||
EditSuccessHandler::New(new) => new,
|
||
EditSuccessHandler::Legacy(legacy) => EditSuccessHandlerNew {
|
||
path: legacy.success_handler,
|
||
extra_args: legacy.success_handler_extra_args,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
lazy_static::lazy_static! {
|
||
pub static ref EMAIL_REGEXP: Regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||
}
|
||
|
||
async fn list_pending_invites(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<WorkspaceInvite>> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let rows = sqlx::query_as!(
|
||
WorkspaceInvite,
|
||
"SELECT
|
||
workspace_invite.workspace_id,
|
||
workspace_invite.email,
|
||
workspace_invite.is_admin,
|
||
workspace_invite.operator,
|
||
workspace.parent_workspace_id
|
||
FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id
|
||
WHERE workspace_id = $1
|
||
ORDER BY workspace_invite.email",
|
||
w_id
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(Json(rows))
|
||
}
|
||
|
||
async fn is_premium(
|
||
_authed: ApiAuthed,
|
||
Extension(_db): Extension<DB>,
|
||
Path(_w_id): Path<String>,
|
||
) -> JsonResult<bool> {
|
||
// Any workspace member (not just admins) may read whether the workspace is on a paid plan: it's a
|
||
// single boolean, and the frontend needs it to decide whether to surface premium-gated affordances
|
||
// (e.g. forking) to non-admin developers too. The `_authed` extractor still enforces membership.
|
||
#[cfg(feature = "cloud")]
|
||
let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id)
|
||
.await?
|
||
.premium;
|
||
#[cfg(not(feature = "cloud"))]
|
||
let premium = false;
|
||
Ok(Json(premium))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct BillableSeatsResponse {
|
||
/// Both omitted when the seats counted are another workspace's: a fork member need not be a
|
||
/// member of the billing root, so the root's headcount is not theirs to read. The total is,
|
||
/// since it is the divisor of the quota their own executions draw on.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
developers: Option<i64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
operators: Option<i64>,
|
||
seats: i64,
|
||
}
|
||
|
||
async fn get_billable_seats(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<BillableSeatsResponse> {
|
||
// Readable by any workspace member, like `is_premium`: this is what the sidebar usage meter
|
||
// divides by, and that meter is shown to non-admin developers too.
|
||
//
|
||
// On cloud a fork draws its plan, quota and bill from the root, so the seats its usage is
|
||
// measured against are the root's. Resolved here rather than by the caller: a fork member need
|
||
// not be a member of that root, and so cannot count its seats from the member list. Off cloud
|
||
// a fork is not billed through a root at all, so the workspace answers for itself.
|
||
#[cfg(feature = "cloud")]
|
||
let billing_w_id = if *CLOUD_HOSTED {
|
||
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await?
|
||
} else {
|
||
w_id.clone()
|
||
};
|
||
#[cfg(not(feature = "cloud"))]
|
||
let billing_w_id = w_id.clone();
|
||
|
||
let counted = windmill_common::workspaces::billable_seats(&db, &billing_w_id).await?;
|
||
let own = billing_w_id == w_id;
|
||
Ok(Json(BillableSeatsResponse {
|
||
developers: own.then_some(counted.developers),
|
||
operators: own.then_some(counted.operators),
|
||
seats: counted.seats,
|
||
}))
|
||
}
|
||
|
||
async fn exists_workspace(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Json(WorkspaceId { id }): Json<WorkspaceId>,
|
||
) -> JsonResult<bool> {
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let exists = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM workspace WHERE workspace.id = $1)",
|
||
id
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
tx.commit().await?;
|
||
Ok(Json(exists))
|
||
}
|
||
|
||
/// Whether this workspace already has an active canonical dev workspace. The create-fork UI can't
|
||
/// rely on the caller's workspace list to decide this — a dev paired to this prod may exist that the
|
||
/// caller isn't a member of — so it asks the server, which sees all children.
|
||
#[derive(Serialize)]
|
||
struct DevWorkspaceInfo {
|
||
id: String,
|
||
name: String,
|
||
dev_workspace_label: Option<String>,
|
||
}
|
||
|
||
/// The environment labels a dev workspace may carry, ordered dev -> prod. Each names the git branch
|
||
/// that workspace deploys to (`dev_workspace_branch`), and every dev workspace in a chain must
|
||
/// carry a distinct one (`reject_dev_label_taken_in_chain`) — so the length of this list is also
|
||
/// the deepest promotion chain. A fixed list rather than free text: the label has to be a usable
|
||
/// single-segment branch name, must not collide with the `wm-fork/**` and `wm_deploy/**` namespaces
|
||
/// git-sync already writes, and must not be a repository's default branch (`main`, `master`).
|
||
pub const DEV_WORKSPACE_LABELS: [&str; 8] = [
|
||
"dev", "qa", "test", "uat", "staging", "demo", "sandbox", "preprod",
|
||
];
|
||
|
||
/// Normalize/validate the dev-workspace environment label. Unset defaults to 'dev', which is also
|
||
/// what a NULL column reads as; any supplied value must be one of `DEV_WORKSPACE_LABELS` exactly,
|
||
/// so the accepted set is what the OpenAPI enum advertises — no trimming, no empty-string alias.
|
||
fn normalize_dev_workspace_label(label: Option<String>) -> Result<Option<String>> {
|
||
let Some(label) = label else {
|
||
return Ok(Some("dev".to_string()));
|
||
};
|
||
if !DEV_WORKSPACE_LABELS.contains(&label.as_str()) {
|
||
return Err(Error::BadRequest(format!(
|
||
"invalid dev workspace label '{label}' (expected one of: {})",
|
||
DEV_WORKSPACE_LABELS.join(", ")
|
||
)));
|
||
}
|
||
Ok(Some(label))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod dev_workspace_label_tests {
|
||
use super::{normalize_dev_workspace_label, tracked_branch_blocks_dev_label};
|
||
|
||
#[test]
|
||
fn tracked_branch_blocks_its_own_name_and_its_namespace() {
|
||
assert!(tracked_branch_blocks_dev_label("uat", "uat"));
|
||
// The label would have to be a ref and a ref directory at once.
|
||
assert!(tracked_branch_blocks_dev_label("release", "release/main"));
|
||
assert!(!tracked_branch_blocks_dev_label("release", "release-main"));
|
||
assert!(!tracked_branch_blocks_dev_label("release", "main"));
|
||
assert!(!tracked_branch_blocks_dev_label("uat", "pre/uat"));
|
||
}
|
||
|
||
fn norm(label: &str) -> Option<String> {
|
||
normalize_dev_workspace_label(Some(label.to_string()))
|
||
.ok()
|
||
.flatten()
|
||
}
|
||
|
||
#[test]
|
||
fn unset_defaults_to_dev() {
|
||
assert_eq!(
|
||
normalize_dev_workspace_label(None).unwrap().as_deref(),
|
||
Some("dev")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn accepts_every_offered_label_and_nothing_else() {
|
||
for label in super::DEV_WORKSPACE_LABELS {
|
||
assert_eq!(norm(label).as_deref(), Some(label), "rejected '{label}'");
|
||
}
|
||
// Off-list names are refused whether or not they would make a usable branch: the list is
|
||
// what keeps a label off `main`/`master` and out of the `wm-fork/**` and `wm_deploy/**`
|
||
// namespaces git-sync writes. Padded and empty values are refused too, so the accepted set
|
||
// is exactly the OpenAPI enum rather than a superset a validating client would reject.
|
||
for label in [
|
||
" uat ",
|
||
"",
|
||
"main",
|
||
"master",
|
||
"wm-fork",
|
||
"wm_deploy",
|
||
"UAT",
|
||
"feature/uat",
|
||
] {
|
||
assert!(
|
||
normalize_dev_workspace_label(Some(label.to_string())).is_err(),
|
||
"accepted '{label}'"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace
|
||
/// settings tab can't rely on the caller's workspace list — a dev paired to this prod may exist that
|
||
/// the caller isn't a member of — so they ask the server, which sees all children. Returns its id/name
|
||
/// so a prod admin who isn't a dev member can still see the pairing and detach it.
|
||
async fn get_dev_workspace(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Option<DevWorkspaceInfo>> {
|
||
let dev = sqlx::query_as!(
|
||
DevWorkspaceInfo,
|
||
"SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?;
|
||
Ok(Json(dev))
|
||
}
|
||
|
||
async fn list_workspaces(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<Vec<Workspace>> {
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let workspaces = sqlx::query_as!(
|
||
Workspace,
|
||
"SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id, workspace.is_dev_workspace, workspace.dev_workspace_label
|
||
FROM workspace
|
||
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
|
||
JOIN usr ON usr.workspace_id = workspace.id
|
||
WHERE usr.email = $1 AND workspace.deleted = false",
|
||
authed.email
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(Json(workspaces))
|
||
}
|
||
|
||
/// Strip the server-only webhook HMAC secret from a `git_sync` blob before it is
|
||
/// returned to a client. The UI never needs it; it stays (encrypted) in the DB.
|
||
fn redact_git_sync_webhook_secrets(git_sync: &mut serde_json::Value) {
|
||
if let Some(repos) = git_sync
|
||
.get_mut("repositories")
|
||
.and_then(|r| r.as_array_mut())
|
||
{
|
||
for repo in repos {
|
||
if let Some(auto_pull) = repo.get_mut("auto_pull").and_then(|a| a.as_object_mut()) {
|
||
auto_pull.remove("webhook_secret");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Zero the server-owned auto-pull fields (webhook id/secret/url/error, synced
|
||
/// sha, last pull status) on a client-supplied `AutoPullSettings`, and stamp who
|
||
/// pulls run as: `saver_email` while auto pull is on. The client only controls
|
||
/// `enabled` / `mode` / `poll_interval_s`; the rest is written by the server (webhook
|
||
/// creation, poller, this save) and must never be trusted from the request —
|
||
/// otherwise a caller could inject a webhook id/secret, fake sync state, or pick who
|
||
/// pulls run as.
|
||
fn sanitize_client_auto_pull(
|
||
auto_pull: &mut windmill_common::workspaces::AutoPullSettings,
|
||
saver_email: &str,
|
||
) {
|
||
auto_pull.webhook_id = None;
|
||
auto_pull.webhook_secret = None;
|
||
auto_pull.webhook_url = None;
|
||
auto_pull.webhook_error = None;
|
||
auto_pull.last_synced_sha = std::collections::HashMap::new();
|
||
auto_pull.last_pull_status = None;
|
||
auto_pull.enabled_by = auto_pull.enabled.then(|| saver_email.to_string());
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod sanitize_client_auto_pull_tests {
|
||
use windmill_common::workspaces::AutoPullSettings;
|
||
|
||
#[test]
|
||
fn a_save_stamps_the_saver_over_any_client_supplied_stamp() {
|
||
let mut ap = AutoPullSettings {
|
||
enabled: true,
|
||
enabled_by: Some("forged@example.com".to_string()),
|
||
..Default::default()
|
||
};
|
||
super::sanitize_client_auto_pull(&mut ap, "saver@example.com");
|
||
assert_eq!(ap.enabled_by.as_deref(), Some("saver@example.com"));
|
||
|
||
ap.enabled = false;
|
||
ap.enabled_by = Some("forged@example.com".to_string());
|
||
super::sanitize_client_auto_pull(&mut ap, "saver@example.com");
|
||
assert_eq!(ap.enabled_by, None, "auto pull off carries no stamp");
|
||
}
|
||
}
|
||
|
||
/// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's
|
||
/// deploy branch. Two ways it can:
|
||
///
|
||
/// - the same name: dev deploys would write straight into the branch the workspace (or its prod)
|
||
/// syncs from, and the CLI refuses that push;
|
||
/// - `label_branch` is the namespace `tracked` sits under (label `release`, tracked
|
||
/// `release/main`): git stores refs hierarchically, so `refs/heads/release` cannot exist
|
||
/// alongside `refs/heads/release/main`.
|
||
///
|
||
/// Either way every deploy job from that workspace would fail.
|
||
fn tracked_branch_blocks_dev_label(label_branch: &str, tracked: &str) -> bool {
|
||
tracked == label_branch || tracked.starts_with(&format!("{label_branch}/"))
|
||
}
|
||
|
||
/// Reject a label whose branch clashes with a tracked branch of any git-sync repository on
|
||
/// `workspace_ids`, before the pairing is created. (`wm-fork` and `wm_deploy`, whose namespaces
|
||
/// exist whatever a repo tracks, are reserved unconditionally in `normalize_dev_workspace_label`.)
|
||
async fn reject_dev_label_matching_tracked_branch(
|
||
db: &DB,
|
||
label: Option<&str>,
|
||
workspace_ids: &[&str],
|
||
) -> Result<()> {
|
||
let label_branch = windmill_common::workspaces::dev_workspace_branch(label);
|
||
for w_id in workspace_ids {
|
||
let Some(settings) = sqlx::query_scalar!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
|
||
w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?
|
||
.flatten()
|
||
.and_then(|v| serde_json::from_value::<WorkspaceGitSyncSettings>(v).ok()) else {
|
||
continue;
|
||
};
|
||
for repo in &settings.repositories {
|
||
let path = repo.git_repo_resource_path.trim_start_matches("$res:");
|
||
let branch: Option<String> = sqlx::query_scalar!(
|
||
"SELECT value->>'branch' FROM resource WHERE workspace_id = $1 AND path = $2",
|
||
w_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?
|
||
.flatten();
|
||
// A repository that pins no branch tracks the remote's default, which cannot be
|
||
// resolved here without a network call — but no offered label is a plausible default
|
||
// (`main`/`master` are off the list), so there is nothing to compare against.
|
||
let Some(tracked) = branch.as_deref().filter(|b| !b.is_empty()) else {
|
||
continue;
|
||
};
|
||
if !tracked_branch_blocks_dev_label(&label_branch, tracked) {
|
||
continue;
|
||
}
|
||
return Err(Error::BadRequest(if tracked == label_branch {
|
||
format!(
|
||
"The environment label '{label_branch}' matches the tracked branch of git-sync \
|
||
repository '{path}' in workspace '{w_id}': deploys from the dev workspace go \
|
||
to the '{label_branch}' branch and would overwrite the branch that repository \
|
||
syncs from. Use a different label or change the repository's branch."
|
||
)
|
||
} else {
|
||
format!(
|
||
"The environment label '{label_branch}' is the namespace of the tracked branch \
|
||
'{tracked}' of git-sync repository '{path}' in workspace '{w_id}': git cannot \
|
||
hold a branch named '{label_branch}' alongside '{tracked}', so every deploy \
|
||
from the dev workspace would fail. Use a different label or change the \
|
||
repository's branch."
|
||
)
|
||
}));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Reject parent-only git-sync settings on a fork workspace. Auto-pull and fork
|
||
/// PRs are configured at the parent: repo → fork sync is routed by the parent's
|
||
/// webhook/poller (`sync_forks`), and a fork-owned auto-pull would register a
|
||
/// second webhook on the same GitHub repo per fork. Promotion mode is rejected
|
||
/// on throwaway forks (their deploys always go to their `wm-fork/**` branch, so
|
||
/// a promotion repo could never take effect) but allowed on a **dev workspace**,
|
||
/// which deploys per-item `wm_deploy/**` branches that promote into the parent.
|
||
async fn reject_parent_only_git_sync_settings_on_fork<'a>(
|
||
db: &DB,
|
||
w_id: &str,
|
||
repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
|
||
) -> Result<()> {
|
||
let row = sqlx::query!(
|
||
"SELECT parent_workspace_id, is_dev_workspace FROM workspace WHERE id = $1",
|
||
w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
let is_fork = row
|
||
.as_ref()
|
||
.and_then(|r| r.parent_workspace_id.as_ref())
|
||
.is_some()
|
||
|| w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX);
|
||
if !is_fork {
|
||
return Ok(());
|
||
}
|
||
let is_dev = row.map(|r| r.is_dev_workspace).unwrap_or(false);
|
||
let offending = repos.into_iter().find_map(|r| {
|
||
if r.auto_pull.as_ref().is_some_and(|a| a.enabled) {
|
||
Some("Auto-pull")
|
||
} else if r.use_individual_branch.unwrap_or(false) && !is_dev {
|
||
Some("Promotion mode")
|
||
} else if r.fork_open_prs {
|
||
Some("Opening PRs for fork deploys")
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
if let Some(offending) = offending {
|
||
return Err(Error::BadRequest(format!(
|
||
"{offending} cannot be configured on a fork workspace: it is managed from the parent workspace's git sync settings"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn get_settings(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<WorkspaceSettings> {
|
||
// Admin-only: this struct contains OAuth secrets, GitHub App tokens, billing
|
||
// info, and other admin-managed integration credentials. Non-admin callers
|
||
// should use `get_public_settings`.
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let settings = sqlx::query_as!(
|
||
WorkspaceSettings,
|
||
r#"
|
||
SELECT
|
||
workspace_id,
|
||
slack_team_id,
|
||
teams_team_id,
|
||
teams_team_name,
|
||
teams_team_guid,
|
||
slack_name,
|
||
slack_command_script,
|
||
teams_command_script,
|
||
slack_email,
|
||
slack_oauth_client_id,
|
||
slack_oauth_client_secret,
|
||
customer_id,
|
||
plan,
|
||
webhook,
|
||
ai_config,
|
||
dbt_warehouses,
|
||
large_file_storage,
|
||
datatable,
|
||
ducklake,
|
||
git_sync,
|
||
deploy_ui,
|
||
default_app,
|
||
default_scripts,
|
||
mute_critical_alerts,
|
||
color,
|
||
operator_settings,
|
||
git_app_installations,
|
||
auto_invite,
|
||
error_handler,
|
||
success_handler,
|
||
public_app_execution_limit_per_minute,
|
||
error_handler_fallback_to_instance_alerts,
|
||
guest_access_enabled,
|
||
guest_jwt_public_key,
|
||
guest_jwt_jwks_url
|
||
FROM
|
||
workspace_settings
|
||
WHERE
|
||
workspace_id = $1
|
||
"#,
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
|
||
|
||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||
tx.commit().await?;
|
||
|
||
if let Some(git_sync) = settings.git_sync.as_mut() {
|
||
redact_git_sync_webhook_secrets(git_sync);
|
||
}
|
||
settings.datatable =
|
||
windmill_common::workspaces::strip_datatable_permissions(settings.datatable.take());
|
||
|
||
Ok(Json(settings))
|
||
}
|
||
|
||
async fn get_public_settings(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<WorkspacePublicSettings> {
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let settings = sqlx::query_as!(
|
||
WorkspacePublicSettings,
|
||
r#"
|
||
SELECT
|
||
workspace_id,
|
||
slack_team_id,
|
||
slack_name,
|
||
teams_team_id,
|
||
teams_team_name,
|
||
teams_team_guid,
|
||
mute_critical_alerts,
|
||
guest_access_enabled,
|
||
deploy_ui,
|
||
large_file_storage,
|
||
datatable
|
||
FROM
|
||
workspace_settings
|
||
WHERE
|
||
workspace_id = $1
|
||
"#,
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?;
|
||
|
||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||
tx.commit().await?;
|
||
settings.datatable =
|
||
windmill_common::workspaces::strip_datatable_permissions(settings.datatable.take());
|
||
|
||
Ok(Json(settings))
|
||
}
|
||
|
||
/// The instance's standing against the guest allowance: counts only, no emails, so any
|
||
/// member may read it. Instance-wide, since a licence is per instance and one email is
|
||
/// one guest however many workspaces it opens; the settings card and the editor's
|
||
/// Guests rung show it so nobody discovers the cap from a visitor's complaint.
|
||
async fn get_guest_usage(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(_w_id): Path<String>,
|
||
) -> JsonResult<windmill_common::workspaces::GuestUsage> {
|
||
Ok(Json(windmill_common::workspaces::guest_usage(&db).await?))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct GitSyncDeployModeQuery {
|
||
/// The branch the caller would push.
|
||
pub branch: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize, Debug)]
|
||
pub struct GitSyncDeployMode {
|
||
/// At least one git-sync repository is configured for this workspace.
|
||
pub configured: bool,
|
||
/// Pushing `branch` deploys via server-side auto-pull: exactly one licensed,
|
||
/// deliverable auto-pull repository tracks it. False (deploy via `git push`
|
||
/// through CI, or `wmill sync push`) when unlicensed, no repo tracks the
|
||
/// branch, or several do — with multiple synced repos we can't tell which the
|
||
/// local checkout is, so the caller asks the user instead.
|
||
pub deploy_on_push: bool,
|
||
}
|
||
|
||
/// Whether an enabled auto-pull repo actually has a delivery path that fires, so
|
||
/// a push really deploys — mirroring the poller/webhook. Polling needs an HTTP(S)
|
||
/// URL (SSH is rejected in the background); webhook-only mode needs an active
|
||
/// hook; `auto` needs either. With neither, `enabled` alone never deploys (e.g. a
|
||
/// webhook that failed to register).
|
||
fn has_runnable_delivery(
|
||
auto_pull: &windmill_common::workspaces::AutoPullSettings,
|
||
resource: &serde_json::Value,
|
||
) -> bool {
|
||
let webhook_active = auto_pull.webhook_id.is_some();
|
||
let is_app = resource
|
||
.get("is_github_app")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false);
|
||
let is_http_url = resource
|
||
.get("url")
|
||
.and_then(|v| v.as_str())
|
||
.map(|u| {
|
||
let u = u.trim_start();
|
||
u.starts_with("https://") || u.starts_with("http://")
|
||
})
|
||
.unwrap_or(false);
|
||
// App repos also have a GitHub-API poll fallback, but require an active
|
||
// webhook here — conservative (errs toward `wmill sync push`) rather than
|
||
// asserting the app installation can mint a token.
|
||
let can_poll = !is_app && is_http_url;
|
||
match auto_pull.mode {
|
||
windmill_common::workspaces::AutoPullMode::Webhook => webhook_active,
|
||
windmill_common::workspaces::AutoPullMode::Polling => can_poll,
|
||
windmill_common::workspaces::AutoPullMode::Auto => webhook_active || can_poll,
|
||
}
|
||
}
|
||
|
||
/// Whether pushing `pushed_branch` matches a repo directly tracking
|
||
/// `tracked_branch` (the non-fork case). A blank tracked branch (repo default) is
|
||
/// unresolvable without a network call, so it never matches and the caller falls
|
||
/// back to `wmill sync push`. Fork/dev routing goes through
|
||
/// `windmill_common::workspaces::resolve_fork_branch_target` instead.
|
||
fn deploys_on_push_branch(pushed_branch: &str, tracked_branch: &str) -> bool {
|
||
!tracked_branch.is_empty() && pushed_branch == tracked_branch
|
||
}
|
||
|
||
/// Non-admin endpoint so the CLI/agent can pick the deploy path (git push vs
|
||
/// `wmill sync push`) without reading the admin-only workspace settings. Takes
|
||
/// only the branch and returns booleans — no repository URLs, credentials, or
|
||
/// webhook config ever leave the backend.
|
||
async fn get_git_sync_deploy_mode(
|
||
_authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Query(q): Query<GitSyncDeployModeQuery>,
|
||
Extension(db): Extension<DB>,
|
||
) -> JsonResult<GitSyncDeployMode> {
|
||
// A fork clears its own auto-pull; its pushes deploy through the root
|
||
// ancestor's repo (which owns `sync_forks`), so evaluate the root's settings.
|
||
let ancestors = windmill_common::workspaces::fork_ancestor_chain(&db, &w_id).await?;
|
||
let is_fork = !ancestors.is_empty();
|
||
let root_id = ancestors.last().cloned().unwrap_or_else(|| w_id.clone());
|
||
|
||
// Polling and webhook delivery both exclude deleted roots, so an archived
|
||
// root (or anything beneath one) can't deploy on push — treat a missing row
|
||
// as archived too.
|
||
let root_deleted = sqlx::query_scalar!("SELECT deleted FROM workspace WHERE id = $1", &root_id)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.unwrap_or(true);
|
||
|
||
// Read on the plain pool: a fork member may not be a member of the root
|
||
// workspace, and only derived booleans are returned (never the settings).
|
||
let git_sync = sqlx::query_scalar!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
|
||
&root_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("getting git_sync settings: {e:#}")))?;
|
||
|
||
let settings = git_sync.flatten().and_then(|v| {
|
||
serde_json::from_value::<WorkspaceGitSyncSettings>(v)
|
||
.map_err(|e| {
|
||
tracing::warn!(
|
||
"git_sync deploy mode: settings deserialize failed for {root_id}: {e}"
|
||
)
|
||
})
|
||
.ok()
|
||
});
|
||
|
||
// Missing settings row / null git_sync means nothing is configured, not a 404.
|
||
let Some(settings) = settings else {
|
||
return Ok(Json(GitSyncDeployMode {
|
||
configured: false,
|
||
deploy_on_push: false,
|
||
}));
|
||
};
|
||
|
||
let configured = !settings.repositories.is_empty();
|
||
|
||
// Auto-pull runs only in builds that compile the poller (`private`); without a
|
||
// caller branch there is nothing to match. Either way deploy_on_push stays
|
||
// false and the caller falls back (git push via CI, or wmill sync push).
|
||
let Some(branch) = q.branch.as_deref() else {
|
||
return Ok(Json(GitSyncDeployMode {
|
||
configured,
|
||
deploy_on_push: false,
|
||
}));
|
||
};
|
||
|
||
// Count the auto-pull repos that would deploy this branch. We deliberately do
|
||
// not check the caller's remote URL: with exactly one such repo the local
|
||
// checkout is unambiguously it, and with several we can't tell which is the
|
||
// caller's, so we report false and let the CLI ask the user.
|
||
let mut matches = 0u32;
|
||
if cfg!(feature = "private") && !root_deleted {
|
||
for repo in &settings.repositories {
|
||
let Some(auto_pull) = repo.auto_pull.as_ref() else {
|
||
continue;
|
||
};
|
||
if !auto_pull.enabled {
|
||
continue;
|
||
}
|
||
// A fork deploys only through the root's sync_forks repos.
|
||
if is_fork && !auto_pull.sync_forks {
|
||
continue;
|
||
}
|
||
// Interpolates `$var:`/`$res:` as the auto-pull poller does.
|
||
// allow_cache=false: an on-demand status must reflect the current
|
||
// repo config, not a value cached by an earlier poll.
|
||
let Some(value) = windmill_store::resources::resolve_git_repository_resource(
|
||
&db,
|
||
&root_id,
|
||
&repo.git_repo_resource_path,
|
||
false,
|
||
)
|
||
.await?
|
||
else {
|
||
continue;
|
||
};
|
||
// `enabled` isn't enough: without a runnable delivery path (active
|
||
// webhook, or pollable non-app HTTPS repo) the push never deploys.
|
||
if !has_runnable_delivery(auto_pull, &value) {
|
||
continue;
|
||
}
|
||
let tracked_branch = value.get("branch").and_then(|v| v.as_str()).unwrap_or("");
|
||
let deploys = if is_fork {
|
||
// Fork/dev routing (wm-fork/* or an env-label branch) resolved by
|
||
// the same logic the auto-pull reconciler uses; this repo counts
|
||
// only if the branch routes to *this* workspace.
|
||
windmill_common::workspaces::resolve_fork_branch_target(
|
||
&db,
|
||
&root_id,
|
||
&repo.git_repo_resource_path,
|
||
branch,
|
||
tracked_branch,
|
||
)
|
||
.await?
|
||
.is_some_and(|(fork_id, _)| fork_id == w_id)
|
||
} else {
|
||
deploys_on_push_branch(branch, tracked_branch)
|
||
};
|
||
if deploys {
|
||
matches += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(Json(GitSyncDeployMode {
|
||
configured,
|
||
deploy_on_push: matches == 1,
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod git_sync_deploy_mode_tests {
|
||
use super::{deploys_on_push_branch, has_runnable_delivery};
|
||
use serde_json::json;
|
||
use windmill_common::workspaces::{AutoPullMode, AutoPullSettings};
|
||
|
||
fn auto_pull(mode: AutoPullMode, webhook_id: Option<i64>) -> AutoPullSettings {
|
||
AutoPullSettings { enabled: true, mode, webhook_id, ..Default::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn runnable_delivery_requires_a_firing_path() {
|
||
let https = json!({ "url": "https://github.com/o/r.git" });
|
||
let ssh = json!({ "url": "git@github.com:o/r.git" });
|
||
let app = json!({ "url": "https://github.com/o/r.git", "is_github_app": true });
|
||
// Polling serves only non-app HTTPS repos (SSH is rejected in background).
|
||
assert!(has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Auto, None),
|
||
&https
|
||
));
|
||
assert!(has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Polling, None),
|
||
&https
|
||
));
|
||
assert!(!has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Polling, None),
|
||
&ssh
|
||
));
|
||
assert!(!has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Auto, None),
|
||
&ssh
|
||
));
|
||
// Webhook-only mode needs an active hook.
|
||
assert!(!has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Webhook, None),
|
||
&https
|
||
));
|
||
assert!(has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Webhook, Some(1)),
|
||
&https
|
||
));
|
||
// App repos are gated on an active webhook here (conservative): their
|
||
// API poll-fallback may still deploy, so this is a safe under-report.
|
||
assert!(!has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Auto, None),
|
||
&app
|
||
));
|
||
assert!(has_runnable_delivery(
|
||
&auto_pull(AutoPullMode::Auto, Some(1)),
|
||
&app
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn non_fork_matches_tracked_branch_only() {
|
||
assert!(deploys_on_push_branch("main", "main"));
|
||
assert!(!deploys_on_push_branch("dev", "main"));
|
||
// An unresolved default (blank) tracked branch never matches.
|
||
assert!(!deploys_on_push_branch("main", ""));
|
||
}
|
||
}
|
||
|
||
async fn get_copilot_settings_state(
|
||
_authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(db): Extension<DB>,
|
||
) -> JsonResult<CopilotSettingsState> {
|
||
let workspace_ai_config = sqlx::query_scalar!(
|
||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
|
||
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
|
||
let instance_ai_config: Option<serde_json::Value> =
|
||
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||
.fetch_optional(&db)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
|
||
|
||
Ok(Json(build_copilot_settings_state(
|
||
has_ai_providers(workspace_ai_config.as_ref()),
|
||
instance_ai_config.as_ref(),
|
||
)))
|
||
}
|
||
|
||
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
|
||
config
|
||
.and_then(|value| value.get("providers"))
|
||
.and_then(|providers| providers.as_object())
|
||
.map(|providers| !providers.is_empty())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub fn build_copilot_settings_state(
|
||
has_workspace_ai_config: bool,
|
||
instance_ai_config: Option<&serde_json::Value>,
|
||
) -> CopilotSettingsState {
|
||
let has_instance_ai_config = has_ai_providers(instance_ai_config);
|
||
CopilotSettingsState {
|
||
has_instance_ai_config,
|
||
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
|
||
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
|
||
}
|
||
}
|
||
|
||
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
|
||
let config = config?;
|
||
if !has_ai_providers(Some(config)) {
|
||
return None;
|
||
}
|
||
let providers = config.get("providers")?.as_object()?;
|
||
|
||
let mut provider_summaries = providers
|
||
.iter()
|
||
.map(|(provider, provider_config)| InstanceAIProviderSummary {
|
||
provider: provider.clone(),
|
||
models: provider_config
|
||
.get("models")
|
||
.and_then(|models| models.as_array())
|
||
.map(|models| {
|
||
models
|
||
.iter()
|
||
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default(),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
|
||
|
||
Some(InstanceAISummary {
|
||
providers: provider_summaries,
|
||
default_model: extract_instance_ai_model_summary(config, "default_model"),
|
||
metadata_model: extract_instance_ai_model_summary(config, "metadata_model"),
|
||
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
|
||
})
|
||
}
|
||
|
||
fn extract_instance_ai_model_summary(
|
||
config: &serde_json::Value,
|
||
key: &str,
|
||
) -> Option<InstanceAIModelSummary> {
|
||
let model_config = config.get(key)?.as_object()?;
|
||
Some(InstanceAIModelSummary {
|
||
provider: model_config.get("provider")?.as_str()?.to_owned(),
|
||
model: model_config.get("model")?.as_str()?.to_owned(),
|
||
})
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct DeployTo {
|
||
deploy_to: Option<String>,
|
||
}
|
||
/// The workspace this one deploys into: its fork parent, or nothing when it is a root. The response
|
||
/// field keeps the `deploy_to` name that predates the fork lineage, so existing clients (the deploy
|
||
/// drawer, the resource/variable editors) read it unchanged.
|
||
async fn get_deploy_to(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<DeployTo> {
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let deploy_to = sqlx::query_scalar!(
|
||
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("getting deploy target: {e:#}")))?
|
||
.flatten();
|
||
|
||
tx.commit().await?;
|
||
Ok(Json(DeployTo { deploy_to }))
|
||
}
|
||
|
||
async fn edit_slack_command(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(es): Json<EditCommandScript>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
let mut tx = db.begin().await?;
|
||
|
||
if es.slack_command_script.is_some() {
|
||
let exists_slack_command_with_team_id = sqlx::query_scalar!(
|
||
r#"
|
||
SELECT EXISTS (SELECT 1
|
||
FROM workspace_settings
|
||
WHERE workspace_id <> $1
|
||
AND slack_command_script IS NOT NULL
|
||
AND slack_team_id IS NOT NULL
|
||
AND slack_team_id = (SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1))
|
||
"#,
|
||
&w_id
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?.unwrap_or(false);
|
||
|
||
if exists_slack_command_with_team_id {
|
||
return Err(Error::BadRequest(
|
||
"A workspace connected to the same slack team already has a command script. Please remove it first."
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET slack_command_script = $1 WHERE workspace_id = $2",
|
||
es.slack_command_script,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_command_script",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some(
|
||
[(
|
||
"script",
|
||
es.slack_command_script
|
||
.unwrap_or("NO_SCRIPT".to_string())
|
||
.as_str(),
|
||
)]
|
||
.into(),
|
||
),
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Edit command script {}", &w_id))
|
||
}
|
||
|
||
async fn run_slack_message_test_job(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<RunSlackMessageTestJobRequest>,
|
||
) -> JsonResult<RunSlackMessageTestJobResponse> {
|
||
let mut fake_result = HashMap::new();
|
||
fake_result.insert("error".to_string(), to_raw_value(&req.test_msg));
|
||
fake_result.insert("success_result".to_string(), to_raw_value(&req.test_msg));
|
||
|
||
let mut extra_args = HashMap::new();
|
||
extra_args.insert("channel".to_string(), to_raw_value(&req.channel));
|
||
extra_args.insert(
|
||
"slack".to_string(),
|
||
to_raw_value(&format!("$res:{WORKSPACE_SLACK_BOT_TOKEN_PATH}")),
|
||
);
|
||
|
||
let uuid = windmill_queue::push_error_handler(
|
||
&db,
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000000")?,
|
||
None,
|
||
Some("slack_message_test".to_string()),
|
||
false,
|
||
w_id.as_str(),
|
||
&format!("script/{}", req.hub_script_path.as_str()),
|
||
sqlx::types::Json(&fake_result),
|
||
None,
|
||
Some(Utc::now()),
|
||
Some(sqlx::types::Json(to_raw_value(&extra_args))),
|
||
authed.email.as_str(),
|
||
false,
|
||
false,
|
||
None, // Note: we could mark it as high priority to return result quickly to the user
|
||
)
|
||
.await?;
|
||
|
||
Ok(Json(RunSlackMessageTestJobResponse {
|
||
job_uuid: uuid.to_string(),
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct SetSlackOAuthConfigRequest {
|
||
slack_oauth_client_id: String,
|
||
slack_oauth_client_secret: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct GetSlackOAuthConfigResponse {
|
||
slack_oauth_client_id: Option<String>,
|
||
slack_oauth_client_secret: Option<String>,
|
||
}
|
||
|
||
async fn get_slack_oauth_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<GetSlackOAuthConfigResponse> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let settings = sqlx::query!(
|
||
"SELECT slack_oauth_client_id, slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?;
|
||
|
||
// Mask the secret if it exists
|
||
let masked_secret = settings
|
||
.slack_oauth_client_secret
|
||
.map(|_| "***".to_string());
|
||
|
||
Ok(Json(GetSlackOAuthConfigResponse {
|
||
slack_oauth_client_id: settings.slack_oauth_client_id,
|
||
slack_oauth_client_secret: masked_secret,
|
||
}))
|
||
}
|
||
|
||
async fn set_slack_oauth_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<SetSlackOAuthConfigRequest>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
if req.slack_oauth_client_id.is_empty() || req.slack_oauth_client_secret.is_empty() {
|
||
return Err(Error::BadRequest(
|
||
"Both client ID and client secret are required".to_string(),
|
||
));
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings
|
||
SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2
|
||
WHERE workspace_id = $3",
|
||
&req.slack_oauth_client_id,
|
||
&req.slack_oauth_client_secret,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.set_slack_oauth_config",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("client_id", req.slack_oauth_client_id.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: "slack_oauth_config".to_string() },
|
||
Some("Slack OAuth config set".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Slack OAuth config set for workspace {}", &w_id))
|
||
}
|
||
|
||
async fn delete_slack_oauth_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings
|
||
SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL
|
||
WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.delete_slack_oauth_config",
|
||
ActionKind::Delete,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: "slack_oauth_config".to_string() },
|
||
Some("Slack OAuth config deleted".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!(
|
||
"Slack OAuth config deleted for workspace {}",
|
||
&w_id
|
||
))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct GetSecondaryStorageNamesQuery {
|
||
#[serde(default)]
|
||
include_default: bool,
|
||
}
|
||
|
||
async fn get_secondary_storage_names(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Query(query): Query<GetSecondaryStorageNamesQuery>,
|
||
) -> JsonResult<Vec<String>> {
|
||
let mut result: Vec<String> = sqlx::query_scalar!(
|
||
"SELECT jsonb_object_keys(large_file_storage->'secondary_storage') AS \"secondary_storage_name!: _\"
|
||
FROM workspace_settings WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_all(&db)
|
||
.await?;
|
||
|
||
// If include_default is true, check if primary storage is set and add "_default_"
|
||
if query.include_default {
|
||
let has_primary_storage: Option<bool> = sqlx::query_scalar!(
|
||
"SELECT (large_file_storage IS NOT NULL
|
||
AND large_file_storage != 'null'::jsonb
|
||
AND jsonb_typeof(large_file_storage) = 'object') AS \"has_primary!\"
|
||
FROM workspace_settings WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?;
|
||
|
||
if has_primary_storage.unwrap_or(false) {
|
||
result.insert(0, "_default_".to_string());
|
||
}
|
||
}
|
||
|
||
Ok(Json(result))
|
||
}
|
||
|
||
pub const BANNED_DOMAINS: &str = include_str!("../../windmill-api/banned_domains.txt");
|
||
pub const MAX_CUSTOM_PROMPT_LENGTH: usize = 5000;
|
||
|
||
async fn is_allowed_auto_domain(ApiAuthed { email, .. }: ApiAuthed) -> JsonResult<bool> {
|
||
let domain = email.split('@').last().unwrap();
|
||
return Ok(Json(!BANNED_DOMAINS.contains(domain)));
|
||
}
|
||
|
||
async fn edit_auto_invite(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(ea): Json<EditAutoInvite>,
|
||
) -> Result<String> {
|
||
crate::workspaces_oss::edit_auto_invite(authed, db, w_id, ea).await
|
||
}
|
||
|
||
#[cfg(feature = "private")]
|
||
async fn edit_instance_groups(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(config): Json<crate::workspaces_ee::EditInstanceGroups>,
|
||
) -> Result<String> {
|
||
crate::workspaces_ee::edit_instance_groups(authed, db, w_id, config).await
|
||
}
|
||
|
||
#[cfg(not(feature = "private"))]
|
||
async fn edit_instance_groups(
|
||
_authed: ApiAuthed,
|
||
Extension(_db): Extension<DB>,
|
||
Path(_w_id): Path<String>,
|
||
Json(_config): Json<serde_json::Value>,
|
||
) -> Result<String> {
|
||
Err(Error::BadRequest(
|
||
"Instance groups are only available on Windmill Enterprise Edition".to_string(),
|
||
))
|
||
}
|
||
|
||
async fn edit_webhook(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(ew): Json<EditWebhook>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
if *CLOUD_HOSTED {
|
||
return Err(Error::BadRequest(
|
||
"Workspace webhooks are not available on cloud-hosted instances".to_string(),
|
||
));
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
if let Some(webhook) = &ew.webhook {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET webhook = $1 WHERE workspace_id = $2",
|
||
webhook,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET webhook = NULL WHERE workspace_id = $1",
|
||
&w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_webhook",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("webhook", &format!("{:?}", ew.webhook)[..])].into()),
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: "webhook".to_string() },
|
||
None,
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Edit webhook for workspace {}", &w_id))
|
||
}
|
||
|
||
/// A warehouse's resource, checked at the one place a warehouse is written.
|
||
///
|
||
/// Same grammar as any Windmill resource path, with the `$res:` prefix optional
|
||
/// because the storage configs are inconsistent about it. Traversal and control
|
||
/// characters are refused here rather than at the far end: an unusable path
|
||
/// stored now surfaces as every dbt run in the workspace failing later, with
|
||
/// nothing pointing back at the settings page that accepted it.
|
||
fn validate_dbt_resource_path(name: &str, path: &str) -> Result<()> {
|
||
let bare = path.strip_prefix("$res:").unwrap_or(path);
|
||
let parts: Vec<&str> = bare.split('/').collect();
|
||
let ok = (bare.starts_with("f/") || bare.starts_with("u/"))
|
||
&& parts.len() >= 3
|
||
&& parts
|
||
.iter()
|
||
.all(|p| !p.is_empty() && *p != "." && *p != "..")
|
||
// `resource.path` is VARCHAR(255), so a longer path names a resource that
|
||
// cannot exist.
|
||
&& bare.chars().count() <= 255
|
||
&& !bare.chars().any(|c| c.is_control() || "?#%\\ ".contains(c));
|
||
if !ok {
|
||
return Err(Error::BadRequest(format!(
|
||
"the dbt warehouse `{name}` names `{path}`, which is not a resource path \
|
||
(`f/<folder>/<name>` or `u/<user>/<name>`, optionally prefixed with `$res:`)"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(serde::Deserialize, serde::Serialize)]
|
||
struct DbtWarehouseConfig {
|
||
resource_path: String,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
target: Option<String>,
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
struct EditDbtWarehouses {
|
||
/// Typed rather than free JSON: a shape the resolver cannot read (a list, a
|
||
/// number where a config belongs) would be accepted here and then fail every
|
||
/// dbt run in the workspace, far from the request that caused it.
|
||
dbt_warehouses: Option<std::collections::HashMap<String, DbtWarehouseConfig>>,
|
||
}
|
||
|
||
/// The workspace's dbt warehouses, by name, each a POINTER to a resource rather
|
||
/// than credentials — like `large_file_storage`. A dbt project can then carry no
|
||
/// connection at all, and the resource keeps its own ACL. Admin-only, because it
|
||
/// decides what every dbt script in the workspace reaches.
|
||
async fn edit_dbt_warehouses(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(new_config): Json<EditDbtWarehouses>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
let new_config = new_config.dbt_warehouses;
|
||
if let Some(map) = new_config.as_ref() {
|
||
for (name, cfg) in map.iter() {
|
||
windmill_common::workspaces::validate_dbt_warehouse_name(name)?;
|
||
validate_dbt_resource_path(name, &cfg.resource_path)?;
|
||
}
|
||
}
|
||
let new_config = new_config
|
||
.map(|m| serde_json::to_value(m))
|
||
.transpose()
|
||
.map_err(|e| Error::internal_err(format!("serializing the dbt warehouses: {e}")))?;
|
||
let mut tx = db.begin().await?;
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_dbt_warehouses",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("dbt_warehouses", format!("{new_config:?}").as_str())].into()),
|
||
)
|
||
.await?;
|
||
let value = new_config.filter(|v| !v.is_null());
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET dbt_warehouses = $1 WHERE workspace_id = $2",
|
||
value,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok("Updated the workspace's dbt warehouses".to_string())
|
||
}
|
||
|
||
async fn edit_large_file_storage_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(new_config): Json<EditLargeFileStorageConfig>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let args_for_audit = format!("{:?}", new_config.large_file_storage);
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_large_file_storage_config",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("large_file_storage", args_for_audit.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
if let Some(lfs_config) = new_config.large_file_storage {
|
||
// `_default_` names the primary storage everywhere else — `get_secondary_storage_names`
|
||
// hands it out, the s3-proxy URL carries it, the clients fall back to it — so a secondary
|
||
// storage of that name is unreachable by design and would shadow the primary for anything
|
||
// that resolves the name. Reject it at the only route that creates one.
|
||
if lfs_config
|
||
.secondary_storage
|
||
.contains_key(windmill_types::s3::DEFAULT_STORAGE)
|
||
{
|
||
return Err(Error::BadRequest(format!(
|
||
"`{}` is reserved for the primary storage and cannot name a secondary one",
|
||
windmill_types::s3::DEFAULT_STORAGE
|
||
)));
|
||
}
|
||
|
||
if !windmill_common::workspaces::filesystem_storage_allowed() {
|
||
let named = std::iter::once(("primary storage", &lfs_config.large_file_storage)).chain(
|
||
lfs_config
|
||
.secondary_storage
|
||
.iter()
|
||
.map(|(name, storage)| (name.as_str(), storage)),
|
||
);
|
||
for (name, storage) in named {
|
||
if matches!(storage, LargeFileStorage::FilesystemStorage(_)) {
|
||
return Err(Error::BadRequest(format!(
|
||
"{name}: {}",
|
||
windmill_common::workspaces::FILESYSTEM_STORAGE_DEV_ONLY_MSG
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
let serialized_lfs_config =
|
||
serde_json::to_value::<LargeFileStorageWithSecondary>(lfs_config)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
// A workspace whose AI session backups fell back to the instance store leaves it
|
||
// here: the generation moves on, so nothing it left in any instance store is read
|
||
// again, whichever one a later return to the fallback finds (`ai_session_backups`).
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET ai_sessions_backup_generation = \
|
||
ai_sessions_backup_generation + 1 \
|
||
WHERE workspace_id = $1 AND large_file_storage IS NULL",
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
|
||
serialized_lfs_config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET large_file_storage = NULL WHERE workspace_id = $1",
|
||
&w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
let backups_generation = sqlx::query_scalar!(
|
||
"SELECT ai_sessions_backup_generation FROM workspace_settings WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
// Read by nothing any more, whatever the storage is now: what the AI session backups
|
||
// left in the instance store under a generation older than the one just committed.
|
||
#[cfg(feature = "parquet")]
|
||
if let Some(generation) = backups_generation {
|
||
crate::ai_session_backups::spawn_delete_fallback(w_id.clone(), generation);
|
||
}
|
||
#[cfg(not(feature = "parquet"))]
|
||
let _ = backups_generation;
|
||
|
||
// Trigger git sync for large file storage changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings {
|
||
setting_type: "large_file_storage".to_string(),
|
||
},
|
||
Some("Large file storage configuration updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!(
|
||
"Edit large file storage config for workspace {}",
|
||
&w_id
|
||
))
|
||
}
|
||
|
||
async fn list_ducklakes(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<String>> {
|
||
let ducklakes = sqlx::query_scalar!(
|
||
r#"
|
||
SELECT jsonb_object_keys(ws.ducklake->'ducklakes') AS ducklake_name
|
||
FROM workspace_settings ws
|
||
WHERE ws.workspace_id = $1
|
||
"#,
|
||
&w_id
|
||
)
|
||
.fetch_all(&db)
|
||
.await?
|
||
.into_iter()
|
||
.filter_map(|s| s)
|
||
.collect();
|
||
|
||
Ok(Json(ducklakes))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct DataTableListItem {
|
||
name: String,
|
||
resource_type: String,
|
||
resource_path: String,
|
||
/// The workspace whose entry governs this one, when it is not this workspace — a fork pointing
|
||
/// at its parent. Its permissions apply here, and only its admins may edit them.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
governing_workspace_id: Option<String>,
|
||
/// Whether the governing entry is under roles.
|
||
permissioned: bool,
|
||
}
|
||
|
||
async fn list_datatables(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<DataTableListItem>> {
|
||
// A pointer entry owns no database, so what it resolves to is the only truthful answer here.
|
||
// One that resolves to nothing — a pointer whose workspace was deleted — is dropped rather than
|
||
// listed with a database it does not have; what happened is named where it is actionable
|
||
// instead: by the delete that stranded it, and by any attempt to use it.
|
||
let resolved =
|
||
windmill_common::workspaces::resolve_workspace_governing_datatables(&db, &w_id).await?;
|
||
|
||
let mut items = Vec::with_capacity(resolved.len());
|
||
for (name, governing) in resolved {
|
||
let database = governing
|
||
.datatable
|
||
.database
|
||
.as_ref()
|
||
.expect("a governing entry owns a database");
|
||
items.push(DataTableListItem {
|
||
name,
|
||
resource_type: database.resource_type.as_ref().to_string(),
|
||
resource_path: database.resource_path.clone(),
|
||
governing_workspace_id: (governing.workspace_id != w_id)
|
||
.then(|| governing.workspace_id.clone()),
|
||
permissioned: governing.datatable.permissions.is_some(),
|
||
});
|
||
}
|
||
|
||
Ok(Json(items))
|
||
}
|
||
|
||
/// Compact column representation: "type" or "type?" for nullable, with "=default" suffix if has default
|
||
type CompactColumn = String;
|
||
|
||
/// Columns mapped by name to their compact type
|
||
type ColumnMap = HashMap<String, CompactColumn>;
|
||
|
||
/// Tables mapped by name to their columns
|
||
type TableMap = HashMap<String, ColumnMap>;
|
||
|
||
/// Schemas mapped by name to their tables
|
||
type SchemaMap = HashMap<String, TableMap>;
|
||
|
||
/// Schemas mapped by name to their table names
|
||
type TableListMap = HashMap<String, Vec<String>>;
|
||
|
||
#[derive(Serialize, Debug)]
|
||
struct DataTableSchema {
|
||
datatable_name: String,
|
||
/// Hierarchical schema: schema_name -> table_name -> column_name -> "type[?][=default]"
|
||
schemas: SchemaMap,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize, Debug)]
|
||
struct DataTableTables {
|
||
datatable_name: String,
|
||
/// Hierarchical metadata: schema_name -> table_names
|
||
schemas: TableListMap,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct GetDataTableSchemaQuery {
|
||
datatable_name: String,
|
||
schema_name: String,
|
||
table_name: String,
|
||
}
|
||
|
||
#[derive(Serialize, Debug)]
|
||
struct DataTableTableSchema {
|
||
datatable_name: String,
|
||
schema_name: String,
|
||
table_name: String,
|
||
columns: ColumnMap,
|
||
}
|
||
|
||
#[derive(Serialize, Debug)]
|
||
struct DataTableConnectionCheck {
|
||
/// The role the data table actually connects as, and the schema its
|
||
/// unqualified statements resolve to. Both are read from the server rather
|
||
/// than the resource, which need not spell either of them out.
|
||
user: String,
|
||
schema: Option<String>,
|
||
/// Whether that role can create tables in `schema` / schemas in the database.
|
||
can_create_table: bool,
|
||
can_create_schema: bool,
|
||
/// Whether the migration bookkeeping table is already present. Informative
|
||
/// only: it explains why migration *tracking* can work without CREATE, and
|
||
/// grants nothing beyond that.
|
||
migrations_table_exists: bool,
|
||
/// Statements to run for the privileges that are missing, empty when there
|
||
/// are none. Windmill connects as the role that lacks them, so it can only
|
||
/// name them for a schema owner to run.
|
||
suggested_grants: Vec<String>,
|
||
/// Statement that gives the session a schema to work in, when `search_path`
|
||
/// resolves to none. Rendered here rather than by the caller so identifier
|
||
/// quoting stays in one place.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
suggested_search_path: Option<String>,
|
||
}
|
||
|
||
/// Report what the data table's own database lets its role do. Surfacing this
|
||
/// from the settings page is the difference between finding out here and finding
|
||
/// out on a first schema change, when the failure reads as a Postgres refusal
|
||
/// deep inside a migration.
|
||
async fn test_datatable_connection(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||
) -> JsonResult<DataTableConnectionCheck> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
// Reports what the admin connection can do, so it answers to the workspace that governs the
|
||
// data table rather than to whichever one is asking.
|
||
windmill_common::workspaces::ensure_datatable_admin_access(
|
||
&db,
|
||
&w_id,
|
||
&datatable_name,
|
||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||
)
|
||
.await?;
|
||
|
||
let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?;
|
||
let pg_db: PgDatabase = serde_json::from_value(db_resource)
|
||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
|
||
let (client, connection) = pg_db.connect(Some(&db)).await?;
|
||
let join_handle = tokio::spawn(async move { connection.await });
|
||
|
||
// One round trip, no side effects: `has_*_privilege` answers for the
|
||
// connected role without attempting the operation.
|
||
let rows = client
|
||
.simple_query(
|
||
"SELECT current_user AS usr, \
|
||
current_schema() AS sch, \
|
||
current_database() AS db, \
|
||
has_schema_privilege(current_schema(), 'CREATE') AS can_create_table, \
|
||
has_database_privilege(current_database(), 'CREATE') AS can_create_schema, \
|
||
to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table",
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!(
|
||
"Failed to inspect data table privileges: {}",
|
||
pg_error_message(&e)
|
||
))
|
||
});
|
||
|
||
drop(client);
|
||
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
|
||
|
||
let row = rows?
|
||
.into_iter()
|
||
.find_map(|msg| match msg {
|
||
tokio_postgres::SimpleQueryMessage::Row(row) => Some(row),
|
||
_ => None,
|
||
})
|
||
.ok_or_else(|| Error::internal_err("Privilege query returned no row".to_string()))?;
|
||
|
||
let user = row.get("usr").unwrap_or_default().to_string();
|
||
let schema = row.get("sch").map(str::to_string);
|
||
let can_create_table = row.get("can_create_table") == Some("t");
|
||
let can_create_schema = row.get("can_create_schema") == Some("t");
|
||
let migrations_table_exists = row.get("has_migrations_table") == Some("t");
|
||
|
||
let quoted_user = render_db_quoted_identifier(&user, DbType::Postgresql);
|
||
let mut suggested_grants = Vec::new();
|
||
// Suggest on the capability alone: an existing `_wm_migrations` spares only
|
||
// that one table, and says nothing about the tables a migration will create.
|
||
// A NULL `current_schema()` means search_path resolves to nothing, and no
|
||
// grant fixes that — an unqualified CREATE fails with `no schema has been
|
||
// selected to create in` whoever holds the privilege — so suggest nothing
|
||
// and let `schema: null` carry the diagnosis.
|
||
if let (false, Some(target)) = (can_create_table, schema.as_deref()) {
|
||
suggested_grants.push(format!(
|
||
"GRANT CREATE ON SCHEMA {} TO {}",
|
||
render_db_quoted_identifier(target, DbType::Postgresql),
|
||
quoted_user
|
||
));
|
||
}
|
||
if !can_create_schema {
|
||
// Named from the server like every other identifier here: behind a
|
||
// pooler the resource's dbname can be an alias for another database.
|
||
let dbname = row.get("db").unwrap_or(pg_db.dbname.as_str());
|
||
suggested_grants.push(format!(
|
||
"GRANT CREATE ON DATABASE {} TO {}",
|
||
render_db_quoted_identifier(dbname, DbType::Postgresql),
|
||
quoted_user
|
||
));
|
||
}
|
||
|
||
// An empty search_path is not a privilege problem, so it gets a statement of
|
||
// its own rather than a grant.
|
||
let suggested_search_path = schema
|
||
.is_none()
|
||
.then(|| format!("ALTER ROLE {quoted_user} SET search_path = public"));
|
||
|
||
Ok(Json(DataTableConnectionCheck {
|
||
user,
|
||
schema,
|
||
can_create_table,
|
||
can_create_schema,
|
||
migrations_table_exists,
|
||
suggested_grants,
|
||
suggested_search_path,
|
||
}))
|
||
}
|
||
|
||
async fn list_datatable_schemas(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<DataTableSchema>> {
|
||
let datatable_names = list_datatable_names(&db, &w_id).await?;
|
||
let mut results = Vec::new();
|
||
|
||
for datatable_name in datatable_names {
|
||
let schema = match get_datatable_schema(&db, &authed, &w_id, &datatable_name).await {
|
||
Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None },
|
||
Err(e) => DataTableSchema {
|
||
datatable_name,
|
||
schemas: HashMap::new(),
|
||
error: Some(e.to_string()),
|
||
},
|
||
};
|
||
results.push(schema);
|
||
}
|
||
|
||
Ok(Json(results))
|
||
}
|
||
|
||
async fn list_datatable_tables(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<DataTableTables>> {
|
||
let datatable_names = list_datatable_names(&db, &w_id).await?;
|
||
let mut results = Vec::new();
|
||
|
||
for datatable_name in datatable_names {
|
||
let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name).await {
|
||
Ok(schemas) => DataTableTables { datatable_name, schemas, error: None },
|
||
Err(e) => DataTableTables {
|
||
datatable_name,
|
||
schemas: HashMap::new(),
|
||
error: Some(e.to_string()),
|
||
},
|
||
};
|
||
results.push(tables);
|
||
}
|
||
|
||
Ok(Json(results))
|
||
}
|
||
|
||
async fn get_datatable_table_schema(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Query(query): Query<GetDataTableSchemaQuery>,
|
||
) -> JsonResult<DataTableTableSchema> {
|
||
let columns = get_datatable_table_columns(
|
||
&db,
|
||
&authed,
|
||
&w_id,
|
||
&query.datatable_name,
|
||
&query.schema_name,
|
||
&query.table_name,
|
||
)
|
||
.await?;
|
||
|
||
Ok(Json(DataTableTableSchema {
|
||
datatable_name: query.datatable_name,
|
||
schema_name: query.schema_name,
|
||
table_name: query.table_name,
|
||
columns,
|
||
}))
|
||
}
|
||
|
||
async fn list_datatable_names(db: &DB, w_id: &str) -> Result<Vec<String>> {
|
||
Ok(sqlx::query_scalar!(
|
||
r#"
|
||
SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name
|
||
FROM workspace_settings ws
|
||
WHERE ws.workspace_id = $1
|
||
"#,
|
||
w_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?
|
||
.into_iter()
|
||
.filter_map(|s| s)
|
||
.collect())
|
||
}
|
||
|
||
/// Connect to a data table as the caller, not as `admin`: the role they named, or the data table's
|
||
/// default. A data table not under roles resolves as `admin`, exactly as it did before roles.
|
||
///
|
||
/// Every schema-browsing query below then reports what this Postgres role can actually reach,
|
||
/// which is why they filter on `has_schema_privilege` — `pg_catalog` is world-readable, so an
|
||
/// unfiltered listing would name schemas the connection cannot even enter.
|
||
async fn resolve_datatable_pg_as_caller(
|
||
db: &DB,
|
||
authed: &ApiAuthed,
|
||
w_id: &str,
|
||
datatable_name: &str,
|
||
) -> Result<PgDatabase> {
|
||
let db_resource = get_datatable_resource_from_db(
|
||
db,
|
||
w_id,
|
||
datatable_name,
|
||
// The data table's default role. Browsing has no way to name another one yet; when the
|
||
// database manager grows a role picker it passes the pick through here.
|
||
None,
|
||
DatatableAccess::Authed(authed.to_authed_ref()),
|
||
)
|
||
.await?;
|
||
serde_json::from_value(db_resource)
|
||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
|
||
}
|
||
|
||
async fn get_datatable_schema(
|
||
db: &DB,
|
||
authed: &ApiAuthed,
|
||
w_id: &str,
|
||
datatable_name: &str,
|
||
) -> Result<SchemaMap> {
|
||
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name).await?;
|
||
|
||
// Connect to the datatable database
|
||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||
|
||
// Spawn the connection handler
|
||
tokio::spawn(async move {
|
||
if let Err(e) = connection.await {
|
||
tracing::error!("Datatable connection error: {}", e);
|
||
}
|
||
});
|
||
|
||
// First, get all non-system schemas (including empty ones)
|
||
let schema_rows = client
|
||
.query(
|
||
r#"
|
||
SELECT nspname::text AS schema_name
|
||
FROM pg_namespace
|
||
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
|
||
AND nspname NOT LIKE 'pg_%'
|
||
AND has_schema_privilege(oid, 'USAGE')
|
||
ORDER BY nspname
|
||
"#,
|
||
&[],
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e)))
|
||
})?;
|
||
|
||
// Build hierarchical structure: schema -> table -> column -> compact_type
|
||
let mut schema_map: SchemaMap = HashMap::new();
|
||
|
||
// Collect schema names and initialize map
|
||
let schema_names: Vec<String> = schema_rows
|
||
.iter()
|
||
.map(|row| {
|
||
let name: String = row.get(0);
|
||
schema_map.entry(name.clone()).or_default();
|
||
name
|
||
})
|
||
.collect();
|
||
|
||
// Query column information only for the schemas we found
|
||
let rows = client
|
||
.query(
|
||
r#"
|
||
SELECT
|
||
table_schema::text,
|
||
table_name::text,
|
||
column_name::text,
|
||
udt_name::text,
|
||
is_nullable::text,
|
||
column_default::text
|
||
FROM information_schema.columns
|
||
WHERE table_schema = ANY($1)
|
||
AND table_name IS NOT NULL
|
||
ORDER BY table_schema, table_name, ordinal_position
|
||
"#,
|
||
&[&schema_names],
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e)))
|
||
})?;
|
||
|
||
for row in rows {
|
||
let table_schema: String = row.get(0);
|
||
let table_name: String = row.get(1);
|
||
let column_name: String = row.get(2);
|
||
let udt_name: String = row.get(3);
|
||
let is_nullable: String = row.get(4);
|
||
let column_default: Option<String> = row.get(5);
|
||
|
||
schema_map
|
||
.entry(table_schema)
|
||
.or_default()
|
||
.entry(table_name)
|
||
.or_default()
|
||
.insert(
|
||
column_name,
|
||
compact_column_type(udt_name, is_nullable, column_default),
|
||
);
|
||
}
|
||
|
||
Ok(schema_map)
|
||
}
|
||
|
||
async fn get_datatable_tables(
|
||
db: &DB,
|
||
authed: &ApiAuthed,
|
||
w_id: &str,
|
||
datatable_name: &str,
|
||
) -> Result<TableListMap> {
|
||
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name).await?;
|
||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||
|
||
tokio::spawn(async move {
|
||
if let Err(e) = connection.await {
|
||
tracing::error!("Datatable connection error: {}", e);
|
||
}
|
||
});
|
||
|
||
let schema_rows = client
|
||
.query(
|
||
r#"
|
||
SELECT nspname::text AS schema_name
|
||
FROM pg_namespace
|
||
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
|
||
AND nspname NOT LIKE 'pg_%'
|
||
AND has_schema_privilege(oid, 'USAGE')
|
||
ORDER BY nspname
|
||
"#,
|
||
&[],
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e)))
|
||
})?;
|
||
|
||
let mut table_map: TableListMap = HashMap::new();
|
||
let schema_names: Vec<String> = schema_rows
|
||
.iter()
|
||
.map(|row| {
|
||
let name: String = row.get(0);
|
||
table_map.entry(name.clone()).or_default();
|
||
name
|
||
})
|
||
.collect();
|
||
|
||
let rows = client
|
||
.query(
|
||
r#"
|
||
SELECT DISTINCT
|
||
table_schema::text,
|
||
table_name::text
|
||
FROM information_schema.columns
|
||
WHERE table_schema = ANY($1)
|
||
AND table_name IS NOT NULL
|
||
ORDER BY table_schema, table_name
|
||
"#,
|
||
&[&schema_names],
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!("Failed to query tables: {}", pg_error_message(&e)))
|
||
})?;
|
||
|
||
for row in rows {
|
||
let table_schema: String = row.get(0);
|
||
let table_name: String = row.get(1);
|
||
table_map.entry(table_schema).or_default().push(table_name);
|
||
}
|
||
|
||
Ok(table_map)
|
||
}
|
||
|
||
async fn get_datatable_table_columns(
|
||
db: &DB,
|
||
authed: &ApiAuthed,
|
||
w_id: &str,
|
||
datatable_name: &str,
|
||
schema_name: &str,
|
||
table_name: &str,
|
||
) -> Result<ColumnMap> {
|
||
if is_system_pg_schema(schema_name) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Schema '{}' is not available for datatable schema lookup",
|
||
schema_name
|
||
)));
|
||
}
|
||
|
||
let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name).await?;
|
||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||
|
||
tokio::spawn(async move {
|
||
if let Err(e) = connection.await {
|
||
tracing::error!("Datatable connection error: {}", e);
|
||
}
|
||
});
|
||
|
||
let rows = client
|
||
.query(
|
||
r#"
|
||
SELECT
|
||
column_name::text,
|
||
udt_name::text,
|
||
is_nullable::text,
|
||
column_default::text
|
||
FROM information_schema.columns
|
||
WHERE table_schema = $1
|
||
AND table_name = $2
|
||
ORDER BY ordinal_position
|
||
"#,
|
||
&[&schema_name, &table_name],
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e)))
|
||
})?;
|
||
|
||
if rows.is_empty() {
|
||
return Err(Error::NotFound(format!(
|
||
"Table '{}.{}' not found in datatable '{}'",
|
||
schema_name, table_name, datatable_name
|
||
)));
|
||
}
|
||
|
||
let mut columns: ColumnMap = HashMap::new();
|
||
for row in rows {
|
||
let column_name: String = row.get(0);
|
||
let udt_name: String = row.get(1);
|
||
let is_nullable: String = row.get(2);
|
||
let column_default: Option<String> = row.get(3);
|
||
columns.insert(
|
||
column_name,
|
||
compact_column_type(udt_name, is_nullable, column_default),
|
||
);
|
||
}
|
||
|
||
Ok(columns)
|
||
}
|
||
|
||
fn is_system_pg_schema(schema_name: &str) -> bool {
|
||
// Match the datatable listing filter: PostgreSQL reserves pg_* schemas for system use.
|
||
matches!(
|
||
schema_name,
|
||
"information_schema" | "pg_toast" | "pg_catalog"
|
||
) || schema_name.starts_with("pg_")
|
||
}
|
||
|
||
fn compact_column_type(
|
||
udt_name: String,
|
||
is_nullable: String,
|
||
column_default: Option<String>,
|
||
) -> String {
|
||
let mut compact = udt_name;
|
||
if is_nullable == "YES" {
|
||
compact.push('?');
|
||
}
|
||
if let Some(default) = column_default {
|
||
compact.push('=');
|
||
compact.push_str(&truncate_column_default(default));
|
||
}
|
||
compact
|
||
}
|
||
|
||
fn truncate_column_default(default: String) -> String {
|
||
const MAX_DEFAULT_CHARS: usize = 30;
|
||
const TRUNCATED_DEFAULT_CHARS: usize = 27;
|
||
|
||
if default.chars().count() > MAX_DEFAULT_CHARS {
|
||
format!(
|
||
"{}...",
|
||
default
|
||
.chars()
|
||
.take(TRUNCATED_DEFAULT_CHARS)
|
||
.collect::<String>()
|
||
)
|
||
} else {
|
||
default
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The header of a pg_dump, followed by an object whose body also holds a `SET`.
|
||
const DUMP: &str = "--\n\
|
||
-- PostgreSQL database dump\n\
|
||
--\n\
|
||
\n\
|
||
\\restrict aBcD\n\
|
||
\n\
|
||
SET statement_timeout = 0;\n\
|
||
SET transaction_timeout = 0;\n\
|
||
SET client_encoding = 'UTF8';\n\
|
||
SELECT pg_catalog.set_config('search_path', '', false);\n\
|
||
\n\
|
||
SET default_table_access_method = heap;\n\
|
||
\n\
|
||
CREATE FUNCTION public.f() RETURNS void LANGUAGE plpgsql AS $$\n\
|
||
BEGIN\n\
|
||
SET transaction_timeout = 0;\n\
|
||
END;\n\
|
||
$$;\n";
|
||
|
||
#[test]
|
||
fn replayable_dump_keeps_everything_but_meta_commands_and_session_timeouts() {
|
||
let replayable = strip_unreplayable_dump_lines(DUMP);
|
||
|
||
assert!(!replayable.contains("\\restrict"));
|
||
assert!(!replayable.contains("SET statement_timeout"));
|
||
assert!(!replayable.contains("SET transaction_timeout = 0;\nSET client_encoding"));
|
||
assert!(replayable.contains("SET client_encoding = 'UTF8';"));
|
||
assert!(replayable.contains("SET default_table_access_method = heap;"));
|
||
// Past the preamble the dump is an object's own text: left exactly as it is.
|
||
assert!(replayable.contains("BEGIN\nSET transaction_timeout = 0;\nEND;"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn dump_preamble_only_drops_settings_the_server_lacks() {
|
||
let dump_file = DumpFile::new().unwrap();
|
||
tokio::fs::write(&dump_file.path, DUMP).await.unwrap();
|
||
let supported = [
|
||
"statement_timeout",
|
||
"client_encoding",
|
||
"default_table_access_method",
|
||
]
|
||
.map(String::from)
|
||
.into_iter()
|
||
.collect();
|
||
|
||
comment_out_unsupported_settings(&dump_file, &supported)
|
||
.await
|
||
.unwrap();
|
||
|
||
let patched = tokio::fs::read_to_string(&dump_file.path).await.unwrap();
|
||
// Rewriting the header must not shift the rest of the dump.
|
||
assert_eq!(patched.len(), DUMP.len());
|
||
assert!(patched.contains("-- transaction_timeout = 0;"));
|
||
assert!(patched.contains("SET statement_timeout = 0;"));
|
||
assert!(patched.contains("SET default_table_access_method = heap;"));
|
||
// The `SET` inside the function body is past the preamble: never touched.
|
||
assert!(patched.contains("BEGIN\nSET transaction_timeout = 0;\nEND;"));
|
||
}
|
||
|
||
#[test]
|
||
fn compact_column_type_truncates_multibyte_defaults_safely() {
|
||
let default = "é".repeat(31);
|
||
|
||
assert_eq!(
|
||
compact_column_type("text".to_string(), "NO".to_string(), Some(default)),
|
||
format!("text={}...", "é".repeat(27))
|
||
);
|
||
}
|
||
|
||
// A real NUL can't live in Rust source, so build `{"k":"<n backslashes>u0000"}`
|
||
// by repeating backslashes: an ODD run before `u0000` is a genuine NUL escape,
|
||
// an EVEN run is an escaped backslash then the literal text "u0000".
|
||
fn nul_json(backslashes: usize) -> String {
|
||
format!(r#"{{"k":"{}u0000"}}"#, "\\".repeat(backslashes))
|
||
}
|
||
|
||
#[test]
|
||
fn nul_escape_detected_only_for_odd_backslash_runs() {
|
||
// 1 backslash: `\u0000` — a genuine NUL escape.
|
||
assert!(json_text_has_nul_escape(&nul_json(1)));
|
||
// 3 backslashes: escaped backslash + genuine NUL escape.
|
||
assert!(json_text_has_nul_escape(&nul_json(3)));
|
||
// 2 backslashes: escaped backslash then literal "u0000" (e.g. minified JS) — safe.
|
||
assert!(!json_text_has_nul_escape(&nul_json(2)));
|
||
// 0 backslashes: the bare token "u0000" — safe.
|
||
assert!(!json_text_has_nul_escape(&nul_json(0)));
|
||
}
|
||
|
||
#[test]
|
||
fn dbt_resource_path_length_bound() {
|
||
let at_limit = format!("f/dbt/{}", "a".repeat(255 - "f/dbt/".len()));
|
||
assert_eq!(at_limit.chars().count(), 255);
|
||
assert!(validate_dbt_resource_path("main", &at_limit).is_ok());
|
||
// The `$res:` prefix is not stored, so it does not count against the column.
|
||
assert!(validate_dbt_resource_path("main", &format!("$res:{at_limit}")).is_ok());
|
||
assert!(validate_dbt_resource_path("main", &format!("{at_limit}a")).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn nul_escape_ignores_clean_values() {
|
||
assert!(!json_text_has_nul_escape(
|
||
r#"{"files":{"/index.tsx":"hello"}}"#
|
||
));
|
||
assert!(!json_text_has_nul_escape(""));
|
||
// A later genuine NUL is still caught even after an earlier even (safe) run.
|
||
assert!(json_text_has_nul_escape(&format!(
|
||
r#"{{"a":"x{b}{b}u0000y","b":"z{b}u0000"}}"#,
|
||
b = "\\"
|
||
)));
|
||
}
|
||
}
|
||
|
||
/// Resolve a source string to PgDatabase credentials with user-scoped permission checks.
|
||
///
|
||
/// For `datatable://name`: the **admin** connection, so it is gated on admin reach. Every caller
|
||
/// copies, dumps or drops a whole database, and a dump taken under a restricted role would be a
|
||
/// silently truncated copy rather than an error — which is worse than refusing.
|
||
/// For `$res:path`: uses UserDB (row-level security) to verify the user can see the resource,
|
||
/// then interpolates `$var:` references in the resource value.
|
||
pub(crate) async fn resolve_pg_source_checked(
|
||
db: &DB,
|
||
user_db: &UserDB,
|
||
authed: &ApiAuthed,
|
||
w_id: &str,
|
||
source: &str,
|
||
) -> Result<PgDatabase> {
|
||
let db_resource = if let Some(name) = source.strip_prefix("datatable://") {
|
||
windmill_common::workspaces::ensure_datatable_admin_access(
|
||
db,
|
||
w_id,
|
||
name,
|
||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||
)
|
||
.await?;
|
||
get_datatable_resource_from_db_unchecked(db, w_id, name).await?
|
||
} else if let Some(path) = source.strip_prefix("$res:") {
|
||
let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed(
|
||
authed,
|
||
db.clone(),
|
||
Some(user_db.clone()),
|
||
);
|
||
let value = windmill_store::resources::get_resource_value_interpolated_internal(
|
||
&db_with_authed,
|
||
w_id,
|
||
path,
|
||
None,
|
||
None,
|
||
false,
|
||
)
|
||
.await?;
|
||
|
||
match value {
|
||
Some(v) => v,
|
||
None => {
|
||
return Err(Error::NotAuthorized(format!(
|
||
"Resource '{}' not found or you do not have access to it",
|
||
path
|
||
)));
|
||
}
|
||
}
|
||
} else {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid source format: '{}'. Expected 'datatable://name' or '$res:path'",
|
||
source
|
||
)));
|
||
};
|
||
|
||
serde_json::from_value(db_resource)
|
||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
|
||
}
|
||
|
||
/// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL
|
||
/// rather than a user resource.
|
||
pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result<bool> {
|
||
// Resolved rather than read: a pointer entry owns no database of its own, so only the entry it
|
||
// lands on can answer. A name that resolves to nothing keeps the historical `false`.
|
||
Ok(resolve_governing_datatable(db, w_id, name)
|
||
.await
|
||
.ok()
|
||
.and_then(|g| g.datatable.database)
|
||
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance))
|
||
}
|
||
|
||
/// Same, for the `datatable://<name>` / `$res:<path>` form the import endpoints take.
|
||
async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result<bool> {
|
||
match source.strip_prefix("datatable://") {
|
||
Some(name) => is_instance_datatable(db, w_id, name).await,
|
||
None => Ok(false),
|
||
}
|
||
}
|
||
|
||
/// A temporary file for pg_dump output that is automatically deleted when dropped.
|
||
pub(crate) struct DumpFile {
|
||
pub(crate) path: std::path::PathBuf,
|
||
}
|
||
|
||
impl DumpFile {
|
||
fn new() -> Result<Self> {
|
||
let dir = std::path::Path::new("/tmp/windmill");
|
||
std::fs::create_dir_all(dir)
|
||
.map_err(|e| Error::internal_err(format!("Failed to create /tmp/windmill: {}", e)))?;
|
||
// Set directory permissions to owner-only
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||
}
|
||
let path = dir.join(format!("datatable_dump_{}", uuid::Uuid::new_v4()));
|
||
// Create the file with restrictive permissions before pg_dump writes to it
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.create(true)
|
||
.mode(0o600)
|
||
.open(&path)
|
||
.map_err(|e| Error::internal_err(format!("Failed to create dump file: {}", e)))?;
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
std::fs::File::create(&path)
|
||
.map_err(|e| Error::internal_err(format!("Failed to create dump file: {}", e)))?;
|
||
}
|
||
Ok(Self { path })
|
||
}
|
||
}
|
||
|
||
impl Drop for DumpFile {
|
||
fn drop(&mut self) {
|
||
if self.path.exists() {
|
||
if let Err(e) = std::fs::remove_file(&self.path) {
|
||
tracing::warn!("Failed to remove dump file {:?}: {}", self.path, e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub(crate) struct PgDumpOptions<'a> {
|
||
pub(crate) schema_only: bool,
|
||
pub(crate) exclude_tables: &'a [&'a str],
|
||
/// Leave out `ALTER ... OWNER TO`.
|
||
pub(crate) no_owner: bool,
|
||
/// Leave out `GRANT`, `REVOKE` and `ALTER DEFAULT PRIVILEGES`.
|
||
pub(crate) no_acl: bool,
|
||
}
|
||
|
||
/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
|
||
/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
|
||
pub(crate) async fn pg_dump_database(
|
||
pg_db: &PgDatabase,
|
||
opts: PgDumpOptions<'_>,
|
||
) -> Result<DumpFile> {
|
||
let dump_file = DumpFile::new()?;
|
||
|
||
let host = &pg_db.host;
|
||
let port = pg_db.port.unwrap_or(5432).to_string();
|
||
let user = pg_db.login_name();
|
||
let dbname = &pg_db.dbname;
|
||
|
||
let mut cmd = tokio::process::Command::new("pg_dump");
|
||
cmd.arg("--format=plain").arg("--file").arg(&dump_file.path);
|
||
if opts.schema_only {
|
||
cmd.arg("--schema-only");
|
||
}
|
||
if opts.no_owner {
|
||
cmd.arg("--no-owner");
|
||
}
|
||
if opts.no_acl {
|
||
cmd.arg("--no-privileges");
|
||
}
|
||
for table in opts.exclude_tables {
|
||
cmd.arg(format!("--exclude-table={table}"));
|
||
}
|
||
cmd.arg("--host")
|
||
.arg(host)
|
||
.arg("--port")
|
||
.arg(&port)
|
||
.arg("--username")
|
||
.arg(user)
|
||
.arg(dbname);
|
||
|
||
if let Some(ref password) = pg_db.password {
|
||
cmd.env("PGPASSWORD", password);
|
||
}
|
||
|
||
if let Some(ref sslmode) = pg_db.sslmode {
|
||
cmd.env("PGSSLMODE", sslmode);
|
||
}
|
||
|
||
let output = cmd
|
||
.output()
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to execute pg_dump: {}", e)))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(Error::internal_err(format!("pg_dump failed: {}", stderr)));
|
||
}
|
||
|
||
Ok(dump_file)
|
||
}
|
||
|
||
/// Whether `line` still belongs to the preamble pg_dump emits before the first
|
||
/// dumped object: comments, blank lines, psql meta-commands and the session `SET`s.
|
||
fn is_dump_preamble_line(line: &[u8]) -> bool {
|
||
let line = line.trim_ascii_start();
|
||
line.is_empty()
|
||
|| line.starts_with(b"--")
|
||
|| line.starts_with(b"\\")
|
||
|| line.starts_with(b"SET ")
|
||
|| line.starts_with(b"SELECT pg_catalog.set_config(")
|
||
}
|
||
|
||
/// The GUCs pg_dump's preamble sets only to keep the dumping session out of the way.
|
||
/// They are also the ones that come and go across versions (`transaction_timeout` is
|
||
/// PG 17+), so they are what a dump replayed on an older server trips over first.
|
||
const DUMP_SESSION_TIMEOUTS: [&str; 4] = [
|
||
"statement_timeout",
|
||
"lock_timeout",
|
||
"idle_in_transaction_session_timeout",
|
||
"transaction_timeout",
|
||
];
|
||
|
||
/// Turn a dump into SQL that can be replayed on another database: drop pg_dump's psql
|
||
/// meta-commands (`\restrict` / `\unrestrict`, not valid SQL) and the session timeouts
|
||
/// its preamble sets, which the replaying server may not have as GUCs at all. Only the
|
||
/// preamble is filtered, so an object's body keeps whatever it holds.
|
||
pub(crate) fn strip_unreplayable_dump_lines(dump: &str) -> String {
|
||
let mut in_preamble = true;
|
||
dump.lines()
|
||
.filter(|line| {
|
||
in_preamble = in_preamble && is_dump_preamble_line(line.as_bytes());
|
||
if line.trim_start().starts_with('\\') {
|
||
return false;
|
||
}
|
||
!(in_preamble
|
||
&& preamble_setting_name(line.as_bytes())
|
||
.is_some_and(|name| DUMP_SESSION_TIMEOUTS.contains(&name)))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
/// The GUC a preamble `SET <name> = ...;` line assigns, if the line is one.
|
||
fn preamble_setting_name(line: &[u8]) -> Option<&str> {
|
||
let name = line.strip_prefix(b"SET ")?.split(|c| *c == b' ').next()?;
|
||
std::str::from_utf8(name).ok()
|
||
}
|
||
|
||
/// The preamble Windmill's postgres client writes can set GUCs an older server does not
|
||
/// have — harmless session tuning, but one failing statement aborts a restore that stops
|
||
/// on the first error. Comment those out in place, three bytes each, so the data
|
||
/// section's offsets stay put.
|
||
async fn comment_out_unsupported_settings(
|
||
dump_file: &DumpFile,
|
||
supported_settings: &HashSet<String>,
|
||
) -> Result<()> {
|
||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||
|
||
let file = tokio::fs::File::open(&dump_file.path)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?;
|
||
let mut reader = tokio::io::BufReader::new(file);
|
||
|
||
let mut preamble: Vec<u8> = Vec::new();
|
||
let mut patched = false;
|
||
loop {
|
||
let start = preamble.len();
|
||
let read = reader
|
||
.read_until(b'\n', &mut preamble)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))?;
|
||
if read == 0 {
|
||
break;
|
||
}
|
||
let line = &preamble[start..];
|
||
if !is_dump_preamble_line(line) {
|
||
preamble.truncate(start);
|
||
break;
|
||
}
|
||
if preamble_setting_name(line).is_some_and(|name| !supported_settings.contains(name)) {
|
||
preamble[start..start + 3].copy_from_slice(b"-- ");
|
||
patched = true;
|
||
}
|
||
}
|
||
if !patched {
|
||
return Ok(());
|
||
}
|
||
|
||
let mut file = tokio::fs::OpenOptions::new()
|
||
.write(true)
|
||
.open(&dump_file.path)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to open dump file: {}", e)))?;
|
||
file.write_all(&preamble)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?;
|
||
file.flush()
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to rewrite dump preamble: {}", e)))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// A psql invocation against `pg_db`, carrying the connection settings the CLI reads
|
||
/// from the environment.
|
||
fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command {
|
||
let mut cmd = tokio::process::Command::new("psql");
|
||
cmd.arg("--host")
|
||
.arg(&pg_db.host)
|
||
.arg("--port")
|
||
.arg(pg_db.port.unwrap_or(5432).to_string())
|
||
.arg("--username")
|
||
.arg(pg_db.login_name())
|
||
.arg("--dbname")
|
||
.arg(&pg_db.dbname)
|
||
.arg("--no-psqlrc")
|
||
.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::piped());
|
||
|
||
if let Some(ref password) = pg_db.password {
|
||
cmd.env("PGPASSWORD", password);
|
||
}
|
||
if let Some(ref sslmode) = pg_db.sslmode {
|
||
cmd.env("PGSSLMODE", sslmode);
|
||
}
|
||
cmd
|
||
}
|
||
|
||
/// GUC names the server backing `pg_db` knows about.
|
||
///
|
||
/// Asked through psql rather than a tokio-postgres connection so the lookup reaches
|
||
/// exactly the servers the restore itself can: libpq negotiates TLS for `sslmode=prefer`
|
||
/// and an unset mode, where `PgDatabase::connect` would hand a TLS-only server a
|
||
/// plaintext socket and fail before the import ever starts.
|
||
async fn server_setting_names(pg_db: &PgDatabase) -> Result<HashSet<String>> {
|
||
let output = psql_command(pg_db)
|
||
.arg("--tuples-only")
|
||
.arg("--no-align")
|
||
.arg("--command")
|
||
.arg("SELECT name FROM pg_settings")
|
||
.output()
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(Error::internal_err(format!(
|
||
"Failed to list the settings of the target server: {}",
|
||
stderr
|
||
)));
|
||
}
|
||
|
||
Ok(String::from_utf8_lossy(&output.stdout)
|
||
.lines()
|
||
.map(|name| name.trim().to_string())
|
||
.filter(|name| !name.is_empty())
|
||
.collect())
|
||
}
|
||
|
||
/// Import a pg_dump file into a target database using psql.
|
||
///
|
||
/// Left to its defaults psql reports a failed statement, carries on and still exits 0,
|
||
/// so a dump that breaks partway through imports partially and reads as a success.
|
||
/// ON_ERROR_STOP surfaces the failure and --single-transaction makes the restore
|
||
/// all-or-nothing, leaving the target as it was and the import retryable.
|
||
async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> {
|
||
let supported_settings = server_setting_names(target_db).await?;
|
||
comment_out_unsupported_settings(dump_file, &supported_settings).await?;
|
||
|
||
let output = psql_command(target_db)
|
||
.arg("--set")
|
||
.arg("ON_ERROR_STOP=1")
|
||
.arg("--single-transaction")
|
||
.arg("--file")
|
||
.arg(&dump_file.path)
|
||
.output()
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to execute psql: {}", e)))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(Error::internal_err(format!(
|
||
"psql import failed: {}",
|
||
stderr
|
||
)));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct CreatePgDatabaseRequest {
|
||
/// The datatable source to determine connection info: 'datatable://name' or '$res:path'
|
||
source: String,
|
||
/// Name for the new database
|
||
target_dbname: String,
|
||
}
|
||
|
||
/// Create a new PostgreSQL database. For instance datatables, creates on the Windmill PG instance.
|
||
/// For resource datatables, creates on the same server as the source.
|
||
async fn create_pg_database(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<CreatePgDatabaseRequest>,
|
||
) -> Result<String> {
|
||
windmill_common::validate_dbname(&req.target_dbname)?;
|
||
|
||
// The copy this database is for is refused a call later, and nothing collects an instance
|
||
// database that no data table entry names. Refuse here too, so the clone stops before one
|
||
// exists rather than leaving an empty registered `wm_fork_…` behind.
|
||
if let Some(reference) = req.source.strip_prefix("datatable://") {
|
||
let (name, _) = parse_datatable_ref_for(&db, &w_id, reference).await?;
|
||
ensure_datatable_is_clonable(&db, &w_id, &name).await?;
|
||
}
|
||
|
||
// Non-superadmin: restrict dbname to wm_fork_ prefix
|
||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||
if !req.target_dbname.starts_with("wm_fork_") {
|
||
return Err(Error::BadRequest(
|
||
"Non-superadmin users can only create databases with names starting with 'wm_fork_'"
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
if is_instance_datatable_source(&db, &w_id, &req.source).await? {
|
||
windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable")
|
||
.await?;
|
||
} else {
|
||
let source_pg =
|
||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||
let (client, connection) = source_pg.connect(Some(&db)).await?;
|
||
let join_handle = tokio::spawn(async move { connection.await });
|
||
|
||
let row = client
|
||
.query_one(
|
||
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)",
|
||
&[&req.target_dbname],
|
||
)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!(
|
||
"Failed to check database existence: {}",
|
||
pg_error_message(&e)
|
||
))
|
||
})?;
|
||
let db_exists: bool = row.get(0);
|
||
|
||
if db_exists {
|
||
drop(client);
|
||
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
|
||
return Err(Error::BadRequest(format!(
|
||
"Database '{}' already exists on the resource server",
|
||
req.target_dbname
|
||
)));
|
||
}
|
||
|
||
client
|
||
.execute(&format!("CREATE DATABASE \"{}\"", &req.target_dbname), &[])
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!(
|
||
"Failed to create database '{}': {}",
|
||
req.target_dbname,
|
||
pg_error_message(&e)
|
||
))
|
||
})?;
|
||
|
||
drop(client);
|
||
windmill_common::shutdown_pg_connection(join_handle).await?;
|
||
}
|
||
|
||
Ok(format!("Created database '{}'", req.target_dbname))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ImportPgDatabaseRequest {
|
||
source: String,
|
||
target: String,
|
||
#[serde(default)]
|
||
target_dbname_override: Option<String>,
|
||
fork_behavior: DataTableForkBehavior,
|
||
}
|
||
|
||
/// Refuse to copy a data table that is under roles.
|
||
///
|
||
/// `pg_dump` carries no roles and the import runs with `--no-privileges`, so a clone arrives with
|
||
/// its objects owned by the admin connection and no `GRANT` for any role. The settings copy brings
|
||
/// `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were
|
||
/// given, and are then denied by Postgres on everything — a data table that looks configured and
|
||
/// answers nothing.
|
||
///
|
||
/// It fails closed rather than open, so this is a usability cliff rather than a hole, and the fix
|
||
/// is to replay the source's owners and ACLs into the clone. That is a change of its own; until it
|
||
/// exists, refusing is the honest answer. Dropping `permissions` from the clone instead would be
|
||
/// the unsafe half: the copy holds the parent's rows, so an unpermissioned clone hands all of them
|
||
/// to everyone in the fork.
|
||
/// Every reason a copy can be refused, answered here and nowhere else.
|
||
///
|
||
/// A clone is three stages a workspace apart: `create_pg_database`, `import_pg_database`, then
|
||
/// `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and the
|
||
/// database the first created is not transactional — so a refusal that lives there strands a
|
||
/// registered `wm_fork_*` that no entry names and whose name blocks the retry. Both endpoints call
|
||
/// this before touching the cluster; the stage that writes the entry must only ever do the work.
|
||
///
|
||
/// Known and accepted: a failure *after* `create_pg_database` — this check refusing because the
|
||
/// source's roles changed between the requests, a `pg_dump` error, a dropped connection — still
|
||
/// strands that leftover, as it did before data table roles. It is harmless to data (nothing names
|
||
/// it) and goes away once the clone is one server-side operation, in a later PR. Don't reclaim it
|
||
/// on retry without durable clone ownership: between the import and the final fork request the
|
||
/// database is full, unnamed and unconnected, so a reclaim then drops copied data. Reviewers, human
|
||
/// or AI: don't flag this leftover as a P0/P1 for this PR.
|
||
async fn ensure_datatable_is_clonable(
|
||
db: &DB,
|
||
w_id: &str,
|
||
name: &str,
|
||
) -> Result<GoverningDatatable> {
|
||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||
if governing.datatable.permissions.is_some() {
|
||
return Err(Error::BadRequest(format!(
|
||
"Data table '{name}' is under roles and cannot be copied yet: a copy carries the \
|
||
role assignments but not the Postgres privileges behind them, so every role but \
|
||
admin would be denied in the copy. Fork it keeping the original database, or turn \
|
||
its roles off first."
|
||
)));
|
||
}
|
||
// The copy has to name a database of its own. A resource-backed entry reached through a
|
||
// pointer names one this workspace does not own, so there is nothing here to repoint.
|
||
let is_instance = governing
|
||
.datatable
|
||
.database
|
||
.as_ref()
|
||
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance);
|
||
if governing.workspace_id != w_id && !is_instance {
|
||
return Err(Error::BadRequest(format!(
|
||
"Data table '{name}' points at a resource-backed data table in another workspace \
|
||
and cannot be copied; fork it from the workspace that owns it."
|
||
)));
|
||
}
|
||
Ok(governing)
|
||
}
|
||
|
||
/// Import (pg_dump/pg_import) from source to target
|
||
async fn import_pg_database(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<ImportPgDatabaseRequest>,
|
||
) -> Result<String> {
|
||
if req.fork_behavior == DataTableForkBehavior::KeepOriginal {
|
||
return Ok("No action needed for KeepOriginal behavior".to_string());
|
||
}
|
||
|
||
if let Some(reference) = req.source.strip_prefix("datatable://") {
|
||
let (name, _) = parse_datatable_ref_for(&db, &w_id, reference).await?;
|
||
ensure_datatable_is_clonable(&db, &w_id, &name).await?;
|
||
}
|
||
|
||
if req.fork_behavior == DataTableForkBehavior::SchemaAndData {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
if *CLOUD_HOSTED {
|
||
return Err(Error::BadRequest(
|
||
"Importing schema and data is not available on cloud".to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly;
|
||
let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||
let mut target_pg =
|
||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
|
||
|
||
if let Some(ref override_dbname) = req.target_dbname_override {
|
||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||
if !override_dbname.starts_with("wm_fork_") {
|
||
return Err(Error::BadRequest(
|
||
"Non-superadmin users can only override target dbname with names starting with 'wm_fork_'"
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
target_pg.dbname = override_dbname.clone();
|
||
}
|
||
windmill_common::validate_dbname(&target_pg.dbname)?;
|
||
|
||
// Ownership never replays: the restore runs as the target's own connection user, and
|
||
// what it creates it owns. Grants do, except around an instance data table — Windmill
|
||
// plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere
|
||
// the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access.
|
||
let no_acl = is_instance_datatable_source(&db, &w_id, &req.target).await?
|
||
|| is_instance_datatable_source(&db, &w_id, &req.source).await?;
|
||
|
||
let dump_file = pg_dump_database(
|
||
&source_pg,
|
||
PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() },
|
||
)
|
||
.await?;
|
||
pg_import_dump(&target_pg, &dump_file).await?;
|
||
|
||
Ok(format!(
|
||
"Imported from '{}' into '{}'",
|
||
req.source, target_pg.dbname
|
||
))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ExportPgSchemaRequest {
|
||
source: String,
|
||
}
|
||
|
||
async fn export_pg_schema(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<ExportPgSchemaRequest>,
|
||
) -> Result<String> {
|
||
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||
let dump_file = pg_dump_database(
|
||
&pg,
|
||
PgDumpOptions { schema_only: true, ..Default::default() },
|
||
)
|
||
.await?;
|
||
tokio::fs::read_to_string(&dump_file.path)
|
||
.await
|
||
.map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct GetDatatableFullSchemaRequest {
|
||
source: String,
|
||
}
|
||
|
||
async fn get_datatable_full_schema(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<GetDatatableFullSchemaRequest>,
|
||
) -> JsonResult<windmill_common::query_builders::FullDatabaseSchema> {
|
||
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||
let (client, connection) = pg.connect(Some(&db)).await?;
|
||
let join_handle = tokio::spawn(async move { connection.await });
|
||
|
||
let result = windmill_common::query_builders::pg_get_full_schema(&client)
|
||
.await
|
||
.map_err(Error::internal_err)?;
|
||
|
||
drop(client);
|
||
windmill_common::shutdown_pg_connection(join_handle).await?;
|
||
|
||
Ok(Json(result))
|
||
}
|
||
|
||
async fn edit_ducklake_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, email, .. }: ApiAuthed,
|
||
Json(new_config): Json<EditDucklakeConfig>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
|
||
|
||
// Lake names end up interpolated in `ATTACH 'ducklake://<name>'`,
|
||
// generated maintenance SQL and the reserved maintenance schedule path
|
||
// (CHECK-constrained to [\w-]+ segments).
|
||
for name in new_config.settings.ducklakes.keys() {
|
||
if !windmill_common::workspaces::is_valid_ducklake_name(name) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid ducklake name '{name}': only letters, digits, '_' and '-' are allowed"
|
||
)));
|
||
}
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let args_for_audit = format!("{:?}", new_config.settings);
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_ducklake_config",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("ducklake", args_for_audit.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
let old_ducklakes = sqlx::query_scalar!(
|
||
r#"
|
||
SELECT ws.ducklake->'ducklakes' AS ducklake_name
|
||
FROM workspace_settings ws
|
||
WHERE ws.workspace_id = $1
|
||
"#,
|
||
&w_id
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(serde_json::Value::Null);
|
||
let old_ducklakes: HashMap<String, Ducklake> =
|
||
serde_json::from_value(old_ducklakes).unwrap_or_default();
|
||
|
||
// Check that non-superadmins are not abusing Instance databases
|
||
if !is_superadmin {
|
||
for (name, dl) in new_config.settings.ducklakes.iter() {
|
||
if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance {
|
||
let old_dl = old_ducklakes.get(name);
|
||
if old_dl.is_none()
|
||
|| old_dl.unwrap().catalog.resource_type
|
||
!= DucklakeCatalogResourceType::Instance
|
||
|| old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Only superadmins can create or modify ducklakes with Instance databases"
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let config: serde_json::Value = serde_json::to_value(&new_config.settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET ducklake = $1 WHERE workspace_id = $2",
|
||
config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Same tx as the settings update: a failed schedule sync/push must fail
|
||
// the whole save — nothing reconciles a half-applied state later.
|
||
let tx = windmill_queue::ducklake_maintenance::sync_ducklake_maintenance_schedules(
|
||
&db,
|
||
tx,
|
||
&w_id,
|
||
&new_config.settings.ducklakes,
|
||
&old_ducklakes,
|
||
&username,
|
||
&email,
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Edit ducklake config for workspace {}", &w_id))
|
||
}
|
||
|
||
/// What a save left behind. `stranded_references` names the data tables in other workspaces that
|
||
/// were governed by one this save deleted — a field rather than a sentence in a success string,
|
||
/// so the UI decides whether to warn on the data rather than on the server's prose.
|
||
#[derive(Serialize)]
|
||
pub struct EditDataTableConfigResult {
|
||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||
stranded_references: Vec<StrandedReference>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct StrandedReference {
|
||
workspace_id: String,
|
||
datatable: String,
|
||
}
|
||
|
||
async fn edit_datatable_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(mut new_config): Json<EditDataTableConfig>,
|
||
) -> JsonResult<EditDataTableConfigResult> {
|
||
require_admin(is_admin, &username)?;
|
||
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// Read under the row lock this transaction will write with. `permissions`, `reference` and
|
||
// `forked_from` are carried across from what this read returns, so a permissions save
|
||
// committing between the read and the whole-document write below would be silently rolled back
|
||
// by it.
|
||
let old_datatables: HashMap<String, DataTable> = serde_json::from_value(
|
||
sqlx::query_scalar!(
|
||
"SELECT ws.datatable->'datatables' FROM workspace_settings ws
|
||
WHERE ws.workspace_id = $1 FOR UPDATE",
|
||
&w_id
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(serde_json::Value::Null),
|
||
)
|
||
.unwrap_or_default();
|
||
|
||
// Validate every persisted data table name and rename segment before
|
||
// touching anything, since they become directory segments in migration
|
||
// storage/export keys (`migrations/datatable/<name>/...`). Names being
|
||
// introduced take the stricter git-sync-safe charset; already-persisted ones
|
||
// keep the historical rule so an old name can still be saved and renamed.
|
||
for name in new_config.settings.datatables.keys() {
|
||
if old_datatables.contains_key(name) {
|
||
crate::datatable_migrations::validate_datatable_path_segment(name)?;
|
||
} else {
|
||
crate::datatable_migrations::validate_new_datatable_name(name)?;
|
||
}
|
||
}
|
||
for r in &new_config.renames {
|
||
crate::datatable_migrations::validate_datatable_path_segment(&r.from)?;
|
||
crate::datatable_migrations::validate_new_datatable_name(&r.to)?;
|
||
}
|
||
// A rename is a claim about what this save is doing, and other workspaces' pointers are
|
||
// rewritten from it — so the claim has to match the configuration it describes, or a caller
|
||
// can move every fork of one data table onto another by asserting a rename that did not
|
||
// happen. The shape below is what "these old keys became those new keys" actually means.
|
||
{
|
||
let old_keys = &old_datatables;
|
||
let new_keys = &new_config.settings.datatables;
|
||
let froms: std::collections::HashSet<&str> =
|
||
new_config.renames.iter().map(|r| r.from.as_str()).collect();
|
||
let tos: std::collections::HashSet<&str> =
|
||
new_config.renames.iter().map(|r| r.to.as_str()).collect();
|
||
if froms.len() != new_config.renames.len() {
|
||
return Err(Error::BadRequest(
|
||
"A data table is renamed twice in one save".to_string(),
|
||
));
|
||
}
|
||
if tos.len() != new_config.renames.len() {
|
||
return Err(Error::BadRequest(
|
||
"Two data tables are renamed to the same name in one save".to_string(),
|
||
));
|
||
}
|
||
for r in &new_config.renames {
|
||
if !old_keys.contains_key(&r.from) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Cannot rename data table '{}': this workspace has no such data table",
|
||
r.from
|
||
)));
|
||
}
|
||
if !new_keys.contains_key(&r.to) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Cannot rename data table '{}' to '{}': the save does not contain '{}'",
|
||
r.from, r.to, r.to
|
||
)));
|
||
}
|
||
// The source has to be gone, or gone-and-reoccupied by another rename — which is what
|
||
// a swap is. Without this, `main -> decoy` passes against a save that keeps both, and
|
||
// every fork of `main` silently follows onto a different data table.
|
||
if new_keys.contains_key(&r.from) && !tos.contains(r.from.as_str()) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Data table '{}' is renamed to '{}' but the save still contains '{}'",
|
||
r.from, r.to, r.from
|
||
)));
|
||
}
|
||
// And the target has to be free, or freed by another rename.
|
||
if old_keys.contains_key(&r.to) && !froms.contains(r.to.as_str()) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Cannot rename data table '{}' to '{}': '{}' already exists",
|
||
r.from, r.to, r.to
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Map new name -> old name so a renamed data table inherits the previous
|
||
// flag instead of being treated as brand new.
|
||
let rename_src: HashMap<&str, &str> = new_config
|
||
.renames
|
||
.iter()
|
||
.map(|r| (r.to.as_str(), r.from.as_str()))
|
||
.collect();
|
||
|
||
// Migrations opt-in is owned by the enable/disable endpoints, not this config
|
||
// form: preserve each existing data table's flag, and default brand-new data
|
||
// tables to enabled.
|
||
// Counted here rather than after the write because this is where a rename is
|
||
// still distinguishable from a creation; emitted once the commit lands.
|
||
let mut created_substrates: Vec<&'static str> = Vec::new();
|
||
for (name, dt) in new_config.settings.datatables.iter_mut() {
|
||
let lookup = rename_src
|
||
.get(name.as_str())
|
||
.copied()
|
||
.unwrap_or(name.as_str());
|
||
let old = old_datatables.get(lookup);
|
||
dt.migrations_enabled = match old {
|
||
Some(old) => old.migrations_enabled,
|
||
None => {
|
||
// Keyed by how the substrate is serialized into `workspace_settings`,
|
||
// so these line up with the `datatable_configured` adoption counts.
|
||
created_substrates.push(match dt.database.as_ref().map(|d| d.resource_type) {
|
||
Some(DataTableCatalogResourceType::Instance) => "instance",
|
||
Some(DataTableCatalogResourceType::Postgresql) => "postgresql",
|
||
None => "reference",
|
||
});
|
||
Some(true)
|
||
}
|
||
};
|
||
// Carried across from the stored entry rather than taken from the request. `permissions`
|
||
// is an access decision, edited through its own endpoint; `reference` is what makes a fork
|
||
// answer to the workspace that governs its data table, and letting a save clear it would
|
||
// hand the fork the database outright. `forked_from` is the clone stamp the fork flow
|
||
// writes: whether an entry has one is carried the same way, since it is what marks the
|
||
// database droppable, but the schema baseline inside it is the diff view's to advance.
|
||
dt.permissions = old.and_then(|old| old.permissions.clone());
|
||
dt.reference = old.and_then(|old| old.reference.clone());
|
||
dt.forked_from = match old.and_then(|old| old.forked_from.as_ref()) {
|
||
Some(stored) => Some(dt.forked_from.take().unwrap_or_else(|| stored.clone())),
|
||
None => None,
|
||
};
|
||
// Carrying the block onto a resource-backed entry would produce a data table the chokepoint
|
||
// refuses on every job — a save that succeeds and breaks everything afterwards. Refuse it
|
||
// instead: turning roles off first is one step, and it keeps discarding an access decision
|
||
// something somebody chose rather than a side effect of moving a database.
|
||
if dt.permissions.is_some()
|
||
&& dt
|
||
.database
|
||
.as_ref()
|
||
.is_some_and(|d| d.resource_type != DataTableCatalogResourceType::Instance)
|
||
{
|
||
return Err(Error::BadRequest(format!(
|
||
"Data table '{name}' is under roles, which only a data table on the instance \
|
||
database can be. Turn its roles off before moving it to a PostgreSQL resource."
|
||
)));
|
||
}
|
||
// A pointer names no database of its own, so the form's empty `database` is correct there.
|
||
if dt.reference.is_some() {
|
||
dt.database = None;
|
||
}
|
||
windmill_common::workspaces::validate_datatable_shape(name, dt)?;
|
||
}
|
||
|
||
let args_for_audit = format!("{:?}", new_config.settings);
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_datatable_config",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("datatable", args_for_audit.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
// Check that non-superadmins are not abusing Instance databases, which reach a database this
|
||
// workspace does not own. Pointing an entry at another workspace's data table is not checked
|
||
// here because it cannot be requested at all: `reference` is overwritten from the stored entry
|
||
// above, for every caller.
|
||
if !is_superadmin {
|
||
for (name, dt) in new_config.settings.datatables.iter() {
|
||
let old_dt = old_datatables.get(name);
|
||
if dt
|
||
.database
|
||
.as_ref()
|
||
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)
|
||
{
|
||
let unchanged = old_dt.and_then(|o| o.database.as_ref()).is_some_and(|o| {
|
||
o.resource_type == DataTableCatalogResourceType::Instance
|
||
&& Some(&o.resource_path) == dt.database.as_ref().map(|d| &d.resource_path)
|
||
});
|
||
if !unchanged {
|
||
return Err(Error::BadRequest(
|
||
"Only superadmins can create or modify data tables with Instance databases"
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Worked out from the locked entries rather than taken from `deleted_datatables`: a settings
|
||
// sync sends the whole map without that list, and dropping a governing entry strands every
|
||
// fork pointing at it all the same.
|
||
let removed: Vec<String> = old_datatables
|
||
.keys()
|
||
.filter(|name| {
|
||
!new_config.settings.datatables.contains_key(*name)
|
||
&& !new_config.renames.iter().any(|r| &r.from == *name)
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
|
||
// A database under roles is reached only through an entry that carries them. Roles follow an
|
||
// entry through a declared rename alone, and a settings sync never declares one, so an entry
|
||
// without roles that newly points at such a database — a name added, or an existing one
|
||
// repointed — would answer everyone there as `admin`. That holds whichever workspace governs it.
|
||
let newly_pointed: Vec<(&String, &str)> = new_config
|
||
.settings
|
||
.datatables
|
||
.iter()
|
||
.filter(|(_, dt)| dt.permissions.is_none())
|
||
.filter_map(|(name, dt)| {
|
||
let db = dt
|
||
.database
|
||
.as_ref()
|
||
.filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?;
|
||
let lookup = rename_src
|
||
.get(name.as_str())
|
||
.copied()
|
||
.unwrap_or(name.as_str());
|
||
let repointed = old_datatables
|
||
.get(lookup)
|
||
.and_then(|old| old.database.as_ref())
|
||
.is_none_or(|old_db| {
|
||
old_db.resource_type != db.resource_type
|
||
|| old_db.resource_path != db.resource_path
|
||
});
|
||
repointed.then_some((name, db.resource_path.as_str()))
|
||
})
|
||
.collect();
|
||
// Another workspace turning roles on for the same database holds only its own settings row, so
|
||
// without this the scan below could read past its uncommitted write.
|
||
windmill_common::datatable_roles::lock_instance_databases_governance(
|
||
&mut *tx,
|
||
newly_pointed.iter().map(|(_, dbname)| *dbname),
|
||
)
|
||
.await?;
|
||
let governed_elsewhere: Vec<String> = if newly_pointed.is_empty() {
|
||
vec![]
|
||
} else {
|
||
sqlx::query_scalar(
|
||
"SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws
|
||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||
WHERE ws.workspace_id <> $1 AND dt.value ? 'permissions'
|
||
AND dt.value->'database'->>'resource_type' = 'instance'",
|
||
)
|
||
.bind(&w_id)
|
||
.fetch_all(&mut *tx)
|
||
.await?
|
||
};
|
||
for (name, dbname) in newly_pointed {
|
||
let governed_here = old_datatables.values().any(|old| {
|
||
old.permissions.is_some()
|
||
&& old.database.as_ref().is_some_and(|d| {
|
||
d.resource_type == DataTableCatalogResourceType::Instance
|
||
&& d.resource_path == dbname
|
||
})
|
||
});
|
||
if governed_here || governed_elsewhere.iter().any(|g| g == dbname) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Data table '{name}' would point at database '{dbname}', which a data table under \
|
||
roles uses, without carrying those roles: everyone reaching '{name}' would connect \
|
||
there as `admin`. Rename the data table under roles from the data table settings, \
|
||
which carries its roles, or turn its roles off first."
|
||
)));
|
||
}
|
||
}
|
||
|
||
let config: serde_json::Value = serde_json::to_value(new_config.settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||
config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let cascaded_migration_paths =
|
||
crate::datatable_migrations::cascade_datatable_migration_renames_and_deletes(
|
||
&db,
|
||
&mut tx,
|
||
&w_id,
|
||
&new_config.renames,
|
||
&new_config.deleted_datatables,
|
||
)
|
||
.await?;
|
||
|
||
// A fork points at a data table by name, so a rename here has to follow or every fork's entry
|
||
// resolves to nothing. In two passes through a temporary name, like the migration cascade one
|
||
// layer down: applied in order, `sa -> sb` then `sb -> sa` would move what pointed at `sa` all
|
||
// the way back to `sa`, and `A -> B`, `B -> C` would carry `A`'s pointers to `C`. Each pointer
|
||
// moves once, from what it named before this save. Inside the transaction: the rename and the
|
||
// pointers that name it are one change, and half of it is a fork whose jobs stop.
|
||
for (i, r) in new_config.renames.iter().enumerate() {
|
||
repoint_datatable_references(&mut tx, &w_id, &r.from, &format!("__wm_rename_tmp/{i}"))
|
||
.await?;
|
||
}
|
||
for (i, r) in new_config.renames.iter().enumerate() {
|
||
repoint_datatable_references(&mut tx, &w_id, &format!("__wm_rename_tmp/{i}"), &r.to)
|
||
.await?;
|
||
}
|
||
|
||
// A deletion cannot be followed the same way — there is nothing to point at any more. Read who
|
||
// is left stranded so the caller is told, the way deleting a workspace does.
|
||
let mut stranded: Vec<StrandedReference> = Vec::new();
|
||
for name in &removed {
|
||
let rows = sqlx::query!(
|
||
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!"
|
||
FROM workspace_settings ws
|
||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||
WHERE dt.value->'reference'->>'workspace_id' = $1
|
||
AND dt.value->'reference'->>'datatable' = $2"#,
|
||
&w_id,
|
||
name,
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
stranded.extend(
|
||
rows.into_iter().map(|r| StrandedReference {
|
||
workspace_id: r.workspace_id,
|
||
datatable: r.datatable,
|
||
}),
|
||
);
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
for substrate in created_substrates {
|
||
windmill_common::feature_usage::log_feature_usage("datatable", "created", substrate);
|
||
}
|
||
|
||
crate::datatable_migrations::record_datatable_cascade_deployments(
|
||
&authed,
|
||
&db,
|
||
&w_id,
|
||
cascaded_migration_paths,
|
||
)
|
||
.await?;
|
||
|
||
Ok(Json(EditDataTableConfigResult {
|
||
stranded_references: stranded,
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct EditGitSyncConfig {
|
||
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug)]
|
||
pub struct EditGitSyncRepository {
|
||
pub git_repo_resource_path: String,
|
||
pub repository: GitRepositorySettings,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug)]
|
||
pub struct DeleteGitSyncRepositoryRequest {
|
||
pub git_repo_resource_path: String,
|
||
}
|
||
|
||
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
|
||
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
|
||
if path.is_empty() {
|
||
return Err(Error::BadRequest(
|
||
"Resource path cannot be empty".to_string(),
|
||
));
|
||
}
|
||
|
||
// Must start with $res: prefix
|
||
if !path.starts_with("$res:") {
|
||
return Err(Error::BadRequest(
|
||
"Resource path must start with '$res:'".to_string(),
|
||
));
|
||
}
|
||
|
||
// Extract the actual path after $res:
|
||
let actual_path = &path[5..]; // Remove "$res:" prefix
|
||
|
||
// Basic validation: must start with f/ or u/ and contain at least one slash
|
||
if !actual_path.starts_with("f/") && !actual_path.starts_with("u/") {
|
||
return Err(Error::BadRequest(
|
||
"Resource path must start with '$res:f/' or '$res:u/'".to_string(),
|
||
));
|
||
}
|
||
|
||
// Must have at least 3 parts (type, folder/user, name)
|
||
let parts: Vec<&str> = actual_path.split('/').collect();
|
||
if parts.len() < 3 || parts.iter().any(|part| part.is_empty()) {
|
||
return Err(Error::BadRequest(
|
||
"Invalid resource path format".to_string(),
|
||
));
|
||
}
|
||
|
||
// Resource name validation (last part)
|
||
let resource_name = parts.last().unwrap();
|
||
if !resource_name
|
||
.chars()
|
||
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Resource name can only contain alphanumeric characters, underscores, and hyphens"
|
||
.to_string(),
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn cleanup_legacy_git_sync_settings_in_memory(
|
||
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
|
||
workspace_id: &str,
|
||
) {
|
||
// Check if all repositories are in new format (have settings field)
|
||
let all_repos_migrated = git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.all(|repo| repo.settings.is_some());
|
||
|
||
// If all repos are migrated and we still have legacy workspace-level settings
|
||
if all_repos_migrated
|
||
&& (git_sync_settings.include_path.is_some() || git_sync_settings.include_type.is_some())
|
||
{
|
||
tracing::info!(
|
||
workspace_id = workspace_id,
|
||
"All git sync repositories migrated to new format, cleaning up legacy workspace-level settings"
|
||
);
|
||
|
||
// Remove workspace-level legacy fields
|
||
git_sync_settings.include_path = None;
|
||
git_sync_settings.include_type = None;
|
||
}
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
|
||
|
||
/// Promotion on a dev workspace needs the dev-aware sync script (hub >= 28796):
|
||
/// an older pinned script bundles a CLI that force-disables per-item branches
|
||
/// on every fork, so enabling promotion would silently keep deploying to the
|
||
/// env-label branch. Reject with an actionable error instead (the dispatcher
|
||
/// demotes inherited configs the same way). Roots run promotion on any script
|
||
/// version, and auto-managed repositories (no pin) always use the latest.
|
||
#[cfg(feature = "enterprise")]
|
||
async fn check_dev_promotion_script_version<'a>(
|
||
db: &DB,
|
||
w_id: &str,
|
||
repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
|
||
) -> Result<()> {
|
||
let mut offending: Option<String> = None;
|
||
for r in repos {
|
||
if !r.use_individual_branch.unwrap_or(false) {
|
||
continue;
|
||
}
|
||
if !r.is_script_meets_min_version(28796)? {
|
||
offending = Some(r.effective_script_path().to_string());
|
||
break;
|
||
}
|
||
}
|
||
let Some(offending) = offending else {
|
||
return Ok(());
|
||
};
|
||
let is_dev = sqlx::query!(
|
||
"SELECT parent_workspace_id, is_dev_workspace FROM workspace WHERE id = $1",
|
||
w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?
|
||
.map(|r| r.is_dev_workspace)
|
||
.unwrap_or(false);
|
||
if !is_dev {
|
||
return Ok(());
|
||
}
|
||
Err(Error::BadRequest(format!(
|
||
"Promotion mode on a dev workspace requires git sync script version 28796 or newer, \
|
||
but this repository pins '{offending}'. Update the pinned sync script (or reset it to \
|
||
auto-managed) first."
|
||
)))
|
||
}
|
||
|
||
/// A dev workspace's promotion must target its parent ("prod") workspace's own
|
||
/// git repository (same URL and branch) — that is what "promote to prod" means.
|
||
/// A fork-created dev inherits prod's repo; an **attached** dev keeps its own,
|
||
/// which may be unrelated. Reject enabling promotion on a repo the parent does
|
||
/// not track so the UI can't present an unrelated repo as prod's target. The
|
||
/// deploy path re-checks the same invariant (a resource edit could break it
|
||
/// after save), via the shared `dev_promotion_target_matches_parent`.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
async fn check_dev_promotion_targets_parent_repo<'a>(
|
||
db: &DB,
|
||
w_id: &str,
|
||
repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
|
||
) -> Result<()> {
|
||
for r in repos.filter(|r| r.use_individual_branch.unwrap_or(false)) {
|
||
let Some(m) = windmill_common::git_sync_ee::dev_promotion_target_mismatch(
|
||
db,
|
||
w_id,
|
||
&r.git_repo_resource_path,
|
||
)
|
||
.await?
|
||
else {
|
||
continue;
|
||
};
|
||
// A resource with no `branch` field normalizes to an empty string.
|
||
let named = |b: &str| {
|
||
if b.is_empty() {
|
||
"<none>".to_string()
|
||
} else {
|
||
b.to_string()
|
||
}
|
||
};
|
||
let branch = named(&m.branch);
|
||
let hint = if m.parent_branches.is_empty() {
|
||
format!(
|
||
"the parent workspace '{}' does not track that repository. Point this repository at \
|
||
the one the parent syncs with, or add it to the parent's git sync settings.",
|
||
m.parent_workspace
|
||
)
|
||
} else {
|
||
// With no branch of its own there is nothing for the parent to add,
|
||
// so only offer the side of the advice that can be acted on.
|
||
let other_way = if m.branch.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(", or add branch '{branch}' to the parent's git sync settings")
|
||
};
|
||
format!(
|
||
"the parent workspace '{}' tracks it on {} '{}'. Set this repository's branch to \
|
||
match{other_way}.",
|
||
m.parent_workspace,
|
||
if m.parent_branches.len() > 1 {
|
||
"branches"
|
||
} else {
|
||
"branch"
|
||
},
|
||
m.parent_branches
|
||
.iter()
|
||
.map(|b| named(b))
|
||
.collect::<Vec<_>>()
|
||
.join("', '")
|
||
)
|
||
};
|
||
return Err(Error::BadRequest(format!(
|
||
"Promotion mode on a dev workspace must reuse the parent workspace's git repository \
|
||
(same URL and branch). Repository '{}' targets '{}' on branch '{branch}', but {hint}",
|
||
r.git_repo_resource_path.trim_start_matches("$res:"),
|
||
m.repo,
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
|
||
Ok(())
|
||
}
|
||
|
||
// Anchor the CE-only query for `cargo sqlx prepare` (which runs with --features enterprise)
|
||
#[cfg(feature = "enterprise")]
|
||
#[allow(dead_code)]
|
||
async fn _sqlx_anchor_ce_user_count(db: &DB, w_id: &str) {
|
||
let _ = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||
w_id
|
||
)
|
||
.fetch_one(db)
|
||
.await;
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
|
||
let user_count: i64 = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||
w_id
|
||
)
|
||
.fetch_one(db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
if user_count > CE_GIT_SYNC_MAX_USERS {
|
||
return Err(Error::BadRequest(format!(
|
||
"Git sync is available for workspaces with up to {} members. \
|
||
Upgrade to Windmill Enterprise Edition for unlimited workspace members.",
|
||
CE_GIT_SYNC_MAX_USERS
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
async fn get_git_sync_enabled(
|
||
_authed: ApiAuthed,
|
||
Extension(_db): Extension<DB>,
|
||
Path(_w_id): Path<String>,
|
||
) -> JsonResult<serde_json::Value> {
|
||
Ok(Json(serde_json::json!({
|
||
"enabled": true,
|
||
"reason": "enterprise",
|
||
"max_repos": null,
|
||
"user_count": null,
|
||
"max_users": null,
|
||
})))
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
async fn get_git_sync_enabled(
|
||
_authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<serde_json::Value> {
|
||
let user_count: i64 = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let enabled = user_count <= CE_GIT_SYNC_MAX_USERS;
|
||
Ok(Json(serde_json::json!({
|
||
"enabled": enabled,
|
||
"reason": if enabled { Some("free_tier") } else { None::<&str> },
|
||
"max_repos": if enabled { Some(1) } else { None::<i32> },
|
||
"user_count": user_count,
|
||
"max_users": CE_GIT_SYNC_MAX_USERS,
|
||
})))
|
||
}
|
||
|
||
async fn edit_git_sync_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(new_config): Json<EditGitSyncConfig>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
check_git_sync_access(&db, &w_id).await?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let args_for_audit = format!("{:?}", new_config.git_sync_settings);
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_git_sync_config",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("git_sync_settings", args_for_audit.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
// The whole-config save only writes the DB below; the managed GitHub webhooks
|
||
// are reconciled after the commit is durable (like the per-repository endpoint):
|
||
// `post_commit` carries the saved repos to reconcile + the hooks of repos this
|
||
// save removed, to delete.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
let post_commit: Option<(WorkspaceGitSyncSettings, Vec<(String, i64)>)>;
|
||
|
||
if let Some(mut git_sync_settings) = new_config.git_sync_settings {
|
||
// Client-supplied server-owned auto-pull state is never trusted: strip it up
|
||
// front, then existing repos re-derive it from `existing` below and new repos
|
||
// stay clean.
|
||
for repo in git_sync_settings.repositories.iter_mut() {
|
||
if let Some(ap) = repo.auto_pull.as_mut() {
|
||
sanitize_client_auto_pull(ap, &authed.email);
|
||
}
|
||
repo.open_pr_error = None;
|
||
repo.credential = None;
|
||
}
|
||
reject_parent_only_git_sync_settings_on_fork(
|
||
&db,
|
||
&w_id,
|
||
git_sync_settings.repositories.iter(),
|
||
)
|
||
.await?;
|
||
// Auto-pull is EE-only (see edit_git_sync_repository).
|
||
#[cfg(not(feature = "enterprise"))]
|
||
if git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled))
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Automatic pull from git is an Enterprise Edition feature".to_string(),
|
||
));
|
||
}
|
||
#[cfg(feature = "enterprise")]
|
||
check_dev_promotion_script_version(&db, &w_id, git_sync_settings.repositories.iter())
|
||
.await?;
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
check_dev_promotion_targets_parent_repo(&db, &w_id, git_sync_settings.repositories.iter())
|
||
.await?;
|
||
// Promotion mode: EE only (mirrors edit_git_sync_repository).
|
||
#[cfg(not(feature = "enterprise"))]
|
||
if git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.any(|r| r.use_individual_branch.unwrap_or(false))
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Promotion mode is an Enterprise Edition feature".to_string(),
|
||
));
|
||
}
|
||
// Preserve server-owned auto-pull state (webhook id/secret, synced sha, last
|
||
// status) that the redacted GET response omits — otherwise a whole-config
|
||
// save from the UI would drop the webhook secret (breaking delivery) or
|
||
// clobber what the poller/webhook layer wrote.
|
||
//
|
||
// `FOR UPDATE` because this is a read-modify-write of the whole `git_sync`
|
||
// blob: the webhook reconciler writes hook fields into it with a targeted
|
||
// update, and without the row lock one landing between this read and the
|
||
// write below would be reverted, leaving its hook live but untracked.
|
||
let existing: Option<WorkspaceGitSyncSettings> = sqlx::query_scalar!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.flatten()
|
||
.and_then(|v| serde_json::from_value(v).ok());
|
||
// Repos present before but absent from this save: their webhooks won't be
|
||
// reconciled below (no longer listed), so capture them for deletion.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
let removed_webhooks: Vec<(String, i64)> = existing
|
||
.as_ref()
|
||
.map(|e| {
|
||
e.repositories
|
||
.iter()
|
||
.filter_map(|old| {
|
||
let hook = old.auto_pull.as_ref().and_then(|a| a.webhook_id)?;
|
||
// The save carries the hook forward (reconciled below) only
|
||
// when the repo is still present AND still has auto_pull — the
|
||
// preservation loop copies webhook fields only onto a Some
|
||
// auto_pull. Otherwise (repo dropped, or auto_pull cleared) the
|
||
// hook would orphan, so delete it.
|
||
let carried = git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.find(|n| n.git_repo_resource_path == old.git_repo_resource_path)
|
||
.map(|n| n.auto_pull.is_some())
|
||
.unwrap_or(false);
|
||
(!carried).then_some((old.git_repo_resource_path.clone(), hook))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
if let Some(existing) = &existing {
|
||
for repo in git_sync_settings.repositories.iter_mut() {
|
||
let Some(old) = existing
|
||
.repositories
|
||
.iter()
|
||
.find(|r| r.git_repo_resource_path == repo.git_repo_resource_path)
|
||
else {
|
||
continue;
|
||
};
|
||
repo.open_pr_error = old.open_pr_error.clone();
|
||
repo.credential = old.credential.clone();
|
||
if let (Some(new_ap), Some(old_ap)) =
|
||
(repo.auto_pull.as_mut(), old.auto_pull.as_ref())
|
||
{
|
||
new_ap.webhook_id = old_ap.webhook_id;
|
||
new_ap.webhook_secret = old_ap.webhook_secret.clone();
|
||
new_ap.webhook_url = old_ap.webhook_url.clone();
|
||
new_ap.last_synced_sha = old_ap.last_synced_sha.clone();
|
||
new_ap.last_pull_status = old_ap.last_pull_status.clone();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up legacy workspace-level settings if all repos are migrated
|
||
cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id);
|
||
|
||
let serialized_config = serde_json::to_value(&git_sync_settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
||
serialized_config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
{
|
||
post_commit = Some((git_sync_settings, removed_webhooks));
|
||
}
|
||
} else {
|
||
// Clearing the whole config removes every repo — delete all their webhooks.
|
||
// `FOR UPDATE` for the same reason as the save branch: the hook ids collected
|
||
// here are used to delete after commit, so a reconcile writing a new id in
|
||
// the gap would leave that hook live with nothing tracking it.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
{
|
||
let existing: Option<WorkspaceGitSyncSettings> = sqlx::query_scalar!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.flatten()
|
||
.and_then(|v| serde_json::from_value(v).ok());
|
||
let removed_webhooks: Vec<(String, i64)> = existing
|
||
.as_ref()
|
||
.map(|e| {
|
||
e.repositories
|
||
.iter()
|
||
.filter_map(|old| {
|
||
old.auto_pull
|
||
.as_ref()
|
||
.and_then(|a| a.webhook_id)
|
||
.map(|h| (old.git_repo_resource_path.clone(), h))
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
post_commit = Some((WorkspaceGitSyncSettings::default(), removed_webhooks));
|
||
}
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET git_sync = NULL WHERE workspace_id = $1",
|
||
&w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
// Post-commit: reconcile each saved repo's managed webhook to match the config
|
||
// and delete the webhooks of repos this save removed. Best-effort — a failure
|
||
// leaves polling on.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
if let Some((mut settings, removed_webhooks)) = post_commit {
|
||
for repo in settings.repositories.iter_mut() {
|
||
// `sync_repo_webhook` writes back the webhook fields it changes itself:
|
||
// the remote hook and the record of it have to move together, so
|
||
// persisting them out here would let one land without the other.
|
||
// Before the webhook reconcile, which decides whether this repo can have
|
||
// one from the credential this records. Also puts a short-lived or
|
||
// under-scoped token in front of the operator while they are still on the
|
||
// settings page, rather than when it expires.
|
||
if let Err(e) = windmill_common::git_sync_ee::refresh_git_credential_status(
|
||
&db,
|
||
&w_id,
|
||
&repo.git_repo_resource_path,
|
||
)
|
||
.await
|
||
{
|
||
tracing::warn!("git credential check error: {}", e);
|
||
}
|
||
if let Err(e) = windmill_common::git_sync_ee::sync_repo_webhook(&db, &w_id, repo).await
|
||
{
|
||
tracing::warn!("git auto-pull: webhook sync error: {}", e);
|
||
}
|
||
}
|
||
for (path, hook_id) in removed_webhooks {
|
||
if let Ok(url) =
|
||
windmill_common::git_sync_ee::resolve_repo_url_interpolated(&db, &w_id, &path).await
|
||
{
|
||
let _ =
|
||
windmill_common::git_sync_ee::delete_repo_webhook(&db, &w_id, &url, hook_id)
|
||
.await;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger git sync for git sync settings changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() },
|
||
Some("Git sync configuration updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Edit git sync config for workspace {}", &w_id))
|
||
}
|
||
|
||
async fn edit_git_sync_repository(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(mut new_config): Json<EditGitSyncRepository>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
check_git_sync_access(&db, &w_id).await?;
|
||
|
||
// Validate the resource path format
|
||
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
|
||
|
||
// Server-owned auto-pull state (webhook id/secret + sync status) is never
|
||
// accepted from the client — the webhook layer and poller own it. Strip it so an
|
||
// existing repo re-derives it from the DB (carried over below) and a new one
|
||
// starts clean.
|
||
if let Some(ap) = new_config.repository.auto_pull.as_mut() {
|
||
sanitize_client_auto_pull(ap, &authed.email);
|
||
}
|
||
new_config.repository.open_pr_error = None;
|
||
new_config.repository.credential = None;
|
||
reject_parent_only_git_sync_settings_on_fork(
|
||
&db,
|
||
&w_id,
|
||
std::iter::once(&new_config.repository),
|
||
)
|
||
.await?;
|
||
|
||
// Auto-pull is EE-only: CE builds compile neither the poller nor the webhook
|
||
// reconciler, so accepting the setting would silently do nothing.
|
||
#[cfg(not(feature = "enterprise"))]
|
||
if new_config
|
||
.repository
|
||
.auto_pull
|
||
.as_ref()
|
||
.is_some_and(|a| a.enabled)
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Automatic pull from git is an Enterprise Edition feature".to_string(),
|
||
));
|
||
}
|
||
#[cfg(feature = "enterprise")]
|
||
check_dev_promotion_script_version(&db, &w_id, std::iter::once(&new_config.repository)).await?;
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
check_dev_promotion_targets_parent_repo(&db, &w_id, std::iter::once(&new_config.repository))
|
||
.await?;
|
||
|
||
// Promotion mode: EE only
|
||
#[cfg(not(feature = "enterprise"))]
|
||
if new_config.repository.use_individual_branch.unwrap_or(false) {
|
||
return Err(Error::BadRequest(
|
||
"Promotion mode is an Enterprise Edition feature".to_string(),
|
||
));
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// First, get the current git sync settings. `FOR UPDATE` because this
|
||
// read-modify-writes the whole `git_sync` blob: the webhook reconciler writes
|
||
// hook fields into it with a targeted update, and one landing between this read
|
||
// and the write below would be reverted, leaving its hook live but untracked.
|
||
let current_settings = sqlx::query!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
|
||
let mut git_sync_settings = if let Some(row) = current_settings {
|
||
if let Some(git_sync) = row.git_sync {
|
||
serde_json::from_value::<WorkspaceGitSyncSettings>(git_sync)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?
|
||
} else {
|
||
WorkspaceGitSyncSettings::default()
|
||
}
|
||
} else {
|
||
WorkspaceGitSyncSettings::default()
|
||
};
|
||
|
||
// Multi-repo: EE only
|
||
#[cfg(not(feature = "enterprise"))]
|
||
{
|
||
let is_new = !git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path);
|
||
if is_new && !git_sync_settings.repositories.is_empty() {
|
||
return Err(Error::BadRequest(
|
||
"Multiple git sync repositories is an Enterprise Edition feature".to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Audit log before we move the repository
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_git_sync_repository",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some(
|
||
[
|
||
(
|
||
"repository_path",
|
||
new_config.git_repo_resource_path.as_str(),
|
||
),
|
||
("repository_data", &format!("{:?}", new_config.repository)),
|
||
]
|
||
.into(),
|
||
),
|
||
)
|
||
.await?;
|
||
|
||
// Check if repository exists before modifying
|
||
let repo_exists = git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.any(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path);
|
||
|
||
// Find and update the specific repository, or add it if it doesn't exist
|
||
let repo_found = git_sync_settings
|
||
.repositories
|
||
.iter_mut()
|
||
.find(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path);
|
||
|
||
if let Some(existing_repo) = repo_found {
|
||
// Update existing repository, but preserve server-owned auto-pull state
|
||
// (synced sha, last pull status, webhook id/secret) so a settings save
|
||
// from the UI cannot revert what the poller/webhook layer wrote.
|
||
let mut updated = new_config.repository;
|
||
updated.open_pr_error = existing_repo.open_pr_error.clone();
|
||
updated.credential = existing_repo.credential.clone();
|
||
match (updated.auto_pull.as_mut(), existing_repo.auto_pull.as_ref()) {
|
||
(Some(new_ap), Some(old_ap)) => {
|
||
new_ap.last_synced_sha = old_ap.last_synced_sha.clone();
|
||
new_ap.last_pull_status = old_ap.last_pull_status.clone();
|
||
new_ap.webhook_id = old_ap.webhook_id;
|
||
new_ap.webhook_secret = old_ap.webhook_secret.clone();
|
||
new_ap.webhook_url = old_ap.webhook_url.clone();
|
||
}
|
||
// UI omitted auto_pull (e.g. older client): keep existing config.
|
||
(None, Some(_)) => {
|
||
updated.auto_pull = existing_repo.auto_pull.clone();
|
||
}
|
||
_ => {}
|
||
}
|
||
*existing_repo = updated;
|
||
} else {
|
||
// Repository doesn't exist, add it as a new repository
|
||
git_sync_settings.repositories.push(new_config.repository);
|
||
}
|
||
|
||
// Clean up legacy workspace-level settings if all repos are migrated
|
||
cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id);
|
||
|
||
// Save the updated configuration first, then reconcile the GitHub webhook to
|
||
// match it *after* the commit is durable (phase 2). Reconciling before the
|
||
// commit could leave the DB pointing at a hook that no longer matches if the
|
||
// save then failed (e.g. a delete on disable); post-commit reconciliation
|
||
// cannot. The pre-edit webhook id/secret are carried over above, so the
|
||
// committed row stays consistent until the reconcile persists any change.
|
||
let serialized_config = serde_json::to_value(&git_sync_settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
||
serialized_config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
// Post-commit: create/remove the webhook to match the saved config. The resulting
|
||
// hook id/secret/url are written back by `sync_repo_webhook` itself. Best-effort —
|
||
// a failure leaves polling on.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
if let Some(repo) = git_sync_settings
|
||
.repositories
|
||
.iter_mut()
|
||
.find(|r| r.git_repo_resource_path == new_config.git_repo_resource_path)
|
||
{
|
||
if let Err(e) = windmill_common::git_sync_ee::refresh_git_credential_status(
|
||
&db,
|
||
&w_id,
|
||
&repo.git_repo_resource_path,
|
||
)
|
||
.await
|
||
{
|
||
tracing::warn!("git credential check error: {}", e);
|
||
}
|
||
if let Err(e) = windmill_common::git_sync_ee::sync_repo_webhook(&db, &w_id, repo).await {
|
||
tracing::warn!("git auto-pull: webhook sync error: {}", e);
|
||
}
|
||
}
|
||
|
||
// Trigger git sync for individual repository update/add
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() },
|
||
Some(format!(
|
||
"Git sync repository '{}' {}",
|
||
new_config.git_repo_resource_path,
|
||
if repo_exists { "updated" } else { "added" }
|
||
)),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!(
|
||
"{} git sync repository '{}' for workspace {}",
|
||
if repo_exists { "Updated" } else { "Added" },
|
||
new_config.git_repo_resource_path,
|
||
&w_id
|
||
))
|
||
}
|
||
|
||
async fn delete_git_sync_repository(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(request): Json<DeleteGitSyncRepositoryRequest>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
// No check_git_sync_access here — admins should always be able to delete/clean up repos
|
||
if request.git_repo_resource_path.is_empty() {
|
||
return Err(Error::BadRequest(
|
||
"Resource path cannot be empty".to_string(),
|
||
));
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// First, get the current git sync settings. `FOR UPDATE` because this
|
||
// read-modify-writes the whole `git_sync` blob: the webhook reconciler writes
|
||
// hook fields into it with a targeted update, and one landing between this read
|
||
// and the write below would be reverted, leaving its hook live but untracked.
|
||
let current_settings = sqlx::query!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
|
||
let mut git_sync_settings = if let Some(row) = current_settings {
|
||
if let Some(git_sync) = row.git_sync {
|
||
serde_json::from_value::<WorkspaceGitSyncSettings>(git_sync)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?
|
||
} else {
|
||
WorkspaceGitSyncSettings::default()
|
||
}
|
||
} else {
|
||
WorkspaceGitSyncSettings::default()
|
||
};
|
||
|
||
// Capture the repo's managed webhook id; the hook itself is deleted only after
|
||
// the DB removal commits (below), so a failed save can't leave the repo pointing
|
||
// at a hook that no longer exists. Deletion bypasses the sync_repo_webhook
|
||
// lifecycle, so GitHub would otherwise keep delivering to an orphaned hook.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
let webhook_to_delete: Option<i64> = git_sync_settings
|
||
.repositories
|
||
.iter()
|
||
.find(|r| r.git_repo_resource_path == request.git_repo_resource_path)
|
||
.and_then(|r| r.auto_pull.as_ref())
|
||
.and_then(|a| a.webhook_id);
|
||
|
||
// Check if repository exists and remove it
|
||
let original_count = git_sync_settings.repositories.len();
|
||
git_sync_settings
|
||
.repositories
|
||
.retain(|repo| repo.git_repo_resource_path != request.git_repo_resource_path);
|
||
|
||
if git_sync_settings.repositories.len() == original_count {
|
||
return Err(Error::BadRequest(format!(
|
||
"Repository with path '{}' not found in git sync configuration",
|
||
request.git_repo_resource_path
|
||
)));
|
||
}
|
||
|
||
// Audit log
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.delete_git_sync_repository",
|
||
ActionKind::Delete,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("repository_path", request.git_repo_resource_path.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
// Clean up legacy workspace-level settings if all repos are migrated
|
||
cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id);
|
||
|
||
// Save the updated configuration
|
||
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
||
serialized_config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
// Removal is durable now — best-effort delete the GitHub webhook.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
if let Some(hook_id) = webhook_to_delete {
|
||
if let Ok(url) = windmill_common::git_sync_ee::resolve_repo_url_interpolated(
|
||
&db,
|
||
&w_id,
|
||
&request.git_repo_resource_path,
|
||
)
|
||
.await
|
||
{
|
||
let _ =
|
||
windmill_common::git_sync_ee::delete_repo_webhook(&db, &w_id, &url, hook_id).await;
|
||
}
|
||
}
|
||
|
||
// The stored credential is deliberately left alone: it belongs to the git
|
||
// repository resource, which this endpoint does not delete, and the resource
|
||
// still authenticates with it for connection tests and commit lookups.
|
||
|
||
// Trigger git sync for repository deletion
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() },
|
||
Some(format!(
|
||
"Git sync repository '{}' deleted",
|
||
request.git_repo_resource_path
|
||
)),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!(
|
||
"Deleted git sync repository '{}' from workspace {}",
|
||
request.git_repo_resource_path, &w_id
|
||
))
|
||
}
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
#[derive(Debug, Deserialize)]
|
||
struct EditDeployUIConfig {
|
||
deploy_ui_settings: Option<WorkspaceDeploymentUISettings>,
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
async fn edit_deploy_ui_config(
|
||
_authed: ApiAuthed,
|
||
Extension(_db): Extension<DB>,
|
||
Path(_w_id): Path<String>,
|
||
) -> Result<String> {
|
||
return Err(Error::BadRequest(
|
||
"Deployment UI is only available on Windmill Enterprise Edition".to_string(),
|
||
));
|
||
}
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
async fn edit_deploy_ui_config(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(new_config): Json<EditDeployUIConfig>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
let args_for_audit = format!("{:?}", new_config.deploy_ui_settings);
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_deploy_ui_config",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("deployment_ui_settings", args_for_audit.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
if let Some(deploy_ui_settings) = new_config.deploy_ui_settings {
|
||
let serialized_config =
|
||
serde_json::to_value::<WorkspaceDeploymentUISettings>(deploy_ui_settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET deploy_ui = $1 WHERE workspace_id = $2",
|
||
serialized_config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET deploy_ui = NULL WHERE workspace_id = $1",
|
||
&w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Edit deployment UI config for workspace {}", &w_id))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct EditDefaultApp {
|
||
#[cfg(feature = "enterprise")]
|
||
pub default_app_path: Option<String>,
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
async fn edit_default_app(
|
||
_authed: ApiAuthed,
|
||
Extension(_db): Extension<DB>,
|
||
Path(_w_id): Path<String>,
|
||
Json(_new_config): Json<EditDefaultApp>,
|
||
) -> Result<String> {
|
||
return Err(Error::BadRequest(
|
||
"Setting a workspace default app is only available on Windmill Enterprise Edition"
|
||
.to_string(),
|
||
));
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct EditGuestAccess {
|
||
guest_access_enabled: bool,
|
||
}
|
||
|
||
/// Turn guest sessions on or off for this workspace. Off by default, and off is
|
||
/// authoritative and immediate: the switch is re-read where a guest session is
|
||
/// minted (`guest_app_admits`) and at the auth door on every guest request, so an app
|
||
/// whose policy already says `guest` — pushed by git-sync, say — closes to guests on
|
||
/// the next request, sessions already issued included.
|
||
async fn edit_guest_access(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
if guest_access_enabled {
|
||
windmill_common::workspaces::require_guest_support()?;
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||
guest_access_enabled,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_guest_access",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&guest_access_enabled.to_string()),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!(
|
||
"Guest access set to {guest_access_enabled} for workspace {w_id}"
|
||
))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct EditGuestJwtKey {
|
||
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
|
||
/// workspace key; verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`)
|
||
/// off cloud, or refuses the JWT if none is set. The off-switch is `guest_access_enabled`.
|
||
public_key: Option<String>,
|
||
jwks_url: Option<String>,
|
||
}
|
||
|
||
/// Configure the key a guest JWT (`jwt_guest_`) is verified against for this workspace.
|
||
/// Workspace-admin gated, like the guest switch: guests are free up to the instance
|
||
/// allowance on any plan, so configuring their key needs no licence. The key is
|
||
/// validated before it is stored so a typo is refused here, not silently on every guest
|
||
/// later: a PEM must parse as an RS/ES public key (HS* has no PEM form and is
|
||
/// unreachable), and a JWKS URL must be fetchable and hold at least one usable signing key.
|
||
async fn edit_guest_jwt_key(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(EditGuestJwtKey { public_key, jwks_url }): Json<EditGuestJwtKey>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
let public_key = public_key.filter(|s| !s.trim().is_empty());
|
||
let jwks_url = jwks_url.filter(|s| !s.trim().is_empty());
|
||
if public_key.is_some() && jwks_url.is_some() {
|
||
return Err(Error::BadRequest(
|
||
"Set a PEM public key or a JWKS URL, not both".to_string(),
|
||
));
|
||
}
|
||
// Clearing stays allowed wherever guests are: a key nobody can use is still worth
|
||
// removing.
|
||
if public_key.is_some() || jwks_url.is_some() {
|
||
windmill_common::workspaces::require_guest_support()?;
|
||
}
|
||
if let Some(pem) = public_key.as_deref() {
|
||
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
|
||
}
|
||
if let Some(url) = jwks_url.as_deref() {
|
||
windmill_common::guest_jwt::fetch_jwks(url).await?;
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET guest_jwt_public_key = $1, guest_jwt_jwks_url = $2 WHERE workspace_id = $3",
|
||
public_key,
|
||
jwks_url,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_guest_jwt_key",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
None,
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Guest JWT key updated for workspace {w_id}"))
|
||
}
|
||
|
||
async fn edit_default_scripts(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(new_config): Json<Option<serde_json::Value>>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_default_scripts",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
if let Some(config) = new_config {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET default_scripts = $1 WHERE workspace_id = $2",
|
||
config,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET default_scripts = NULL WHERE workspace_id = $1",
|
||
&w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
tx.commit().await?;
|
||
|
||
// Trigger git sync for default scripts changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "default_scripts".to_string() },
|
||
Some("Default scripts configuration updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Edit default scripts for workspace {}", &w_id))
|
||
}
|
||
|
||
async fn get_default_scripts(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Option<serde_json::Value>> {
|
||
let mut tx = db.begin().await?;
|
||
let default_scripts = sqlx::query_scalar!(
|
||
"SELECT default_scripts FROM workspace_settings WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await
|
||
.map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?;
|
||
tx.commit().await?;
|
||
|
||
Ok(Json(default_scripts.flatten()))
|
||
}
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
async fn edit_default_app(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(new_config): Json<EditDefaultApp>,
|
||
) -> Result<String> {
|
||
#[cfg(not(feature = "enterprise"))]
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Setting a workspace default app is only available on Windmill Enterprise Edition"
|
||
.to_string(),
|
||
));
|
||
}
|
||
|
||
require_admin(is_admin, &username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let args_for_audit = format!("{:?}", new_config.default_app_path);
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_default_app",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("default_app", args_for_audit.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
if let Some(default_app_path) = new_config.default_app_path {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET default_app = $1 WHERE workspace_id = $2",
|
||
default_app_path,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET default_app = NULL WHERE workspace_id = $1",
|
||
&w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
tx.commit().await?;
|
||
|
||
// Trigger git sync for default app changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "default_app".to_string() },
|
||
Some("Default app configuration updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Edit default app for workspace {}", &w_id))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct WorkspaceDefaultApp {
|
||
pub default_app_path: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub default_app_raw: Option<bool>,
|
||
}
|
||
async fn get_default_app(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<WorkspaceDefaultApp> {
|
||
let row = sqlx::query!(
|
||
"SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"
|
||
FROM workspace_settings ws
|
||
LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id
|
||
LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]
|
||
WHERE ws.workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await
|
||
.map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?;
|
||
|
||
Ok(Json(WorkspaceDefaultApp {
|
||
default_app_path: row.default_app_path,
|
||
default_app_raw: row.default_app_raw,
|
||
}))
|
||
}
|
||
|
||
async fn edit_error_handler(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(ee): Json<EditErrorHandler>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
// Normalize to new format (handles both old CLI and new CLI requests)
|
||
let ee = ee.into_normalized();
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
if let Some(fallback_to_instance_alerts) = ee.fallback_to_instance_alerts {
|
||
if fallback_to_instance_alerts {
|
||
ensure_instance_alert_fallback_allowed(&mut tx, &w_id).await?;
|
||
}
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = $1 WHERE workspace_id = $2",
|
||
fallback_to_instance_alerts,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
sqlx::query_as!(
|
||
Group,
|
||
"INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||
w_id,
|
||
"error_handler",
|
||
"The group the error handler acts on behalf of",
|
||
serde_json::json!({username_to_permissioned_as(&authed.username): true})
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
if let Some(path) = &ee.path {
|
||
match ee.extra_args.as_ref() {
|
||
Some(extra_args) if extra_args.is_object() => {
|
||
let Ok(email_recipients) = serde_json::from_value::<Option<Vec<String>>>(
|
||
extra_args["email_recipients"].to_owned(),
|
||
) else {
|
||
return Err(Error::BadRequest(
|
||
"Field `email_recipients` expected to be JSON array".to_string(),
|
||
));
|
||
};
|
||
|
||
if let Some(email_recipients) = email_recipients {
|
||
for email in email_recipients {
|
||
if !EMAIL_REGEXP.is_match(&email) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid email format: {}",
|
||
email
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None => {}
|
||
_ => {
|
||
return Err(Error::BadRequest(
|
||
"Field `extra_args` expected to be JSON object".to_string(),
|
||
))
|
||
}
|
||
}
|
||
|
||
// Always persist `muted_on_cancel` and `muted_on_user_path` (including
|
||
// false values) so that a YAML round-trip via `wmill sync pull && wmill
|
||
// sync push` is stable instead of re-firing `editErrorHandler` on every
|
||
// push (the CLI sends `false` defaults and deepEqual would otherwise
|
||
// mismatch an omitted-on-write shape against an always-sent-by-CLI one).
|
||
let mut error_handler = serde_json::json!({
|
||
"path": path,
|
||
"muted_on_cancel": ee.muted_on_cancel,
|
||
"muted_on_user_path": ee.muted_on_user_path,
|
||
});
|
||
if let Some(extra_args) = &ee.extra_args {
|
||
error_handler["extra_args"] = extra_args.clone();
|
||
}
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET error_handler = $1 WHERE workspace_id = $2",
|
||
error_handler,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET error_handler = NULL WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_error_handler",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some(
|
||
[
|
||
("error_handler", &format!("{:?}", ee.path)[..]),
|
||
(
|
||
"fallback_to_instance_alerts",
|
||
&format!("{:?}", ee.fallback_to_instance_alerts)[..],
|
||
),
|
||
]
|
||
.into(),
|
||
),
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
// Trigger git sync for error handler changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "error_handler".to_string() },
|
||
Some("Error handler configuration updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Edit error_handler for workspace {}", &w_id))
|
||
}
|
||
|
||
async fn edit_success_handler(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(es): Json<EditSuccessHandler>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
// Normalize to new format (handles both old CLI and new CLI requests)
|
||
let es = es.into_normalized();
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
sqlx::query_as!(
|
||
Group,
|
||
"INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||
w_id,
|
||
"success_handler",
|
||
"The group the success handler acts on behalf of",
|
||
serde_json::json!({username_to_permissioned_as(&authed.username): true})
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
if let Some(path) = &es.path {
|
||
let mut success_handler = serde_json::json!({
|
||
"path": path,
|
||
});
|
||
if let Some(extra_args) = &es.extra_args {
|
||
success_handler["extra_args"] = extra_args.clone();
|
||
}
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET success_handler = $1 WHERE workspace_id = $2",
|
||
success_handler,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET success_handler = NULL WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.edit_success_handler",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some([("success_handler", &format!("{:?}", es.path)[..])].into()),
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
// Trigger git sync for success handler changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "success_handler".to_string() },
|
||
Some("Success handler configuration updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Edit success_handler for workspace {}", &w_id))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct NewEnvironmentVariable {
|
||
name: String,
|
||
value: Option<String>,
|
||
}
|
||
|
||
async fn set_environment_variable(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(NewEnvironmentVariable { value, name }): Json<NewEnvironmentVariable>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
match value {
|
||
Some(value) => {
|
||
// The worker escapes the name when it splices it into the NativeTS/Bun
|
||
// prologue, so this is a friendly guard against new non-identifier
|
||
// names, not the injection defense. Skip it for names that already
|
||
// exist so a value edit of a grandfathered name (the edit UI resubmits
|
||
// the name) isn't rejected with no in-product way to fix it.
|
||
if !windmill_common::variables::is_valid_js_identifier(&name) {
|
||
let already_exists = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM workspace_env WHERE workspace_id = $1 AND name = $2)",
|
||
&w_id,
|
||
name
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
if !already_exists {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid environment variable name '{name}': must start with a letter, underscore or '$' and contain only letters, digits, underscores or '$'"
|
||
)));
|
||
}
|
||
}
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_env (workspace_id, name, value) VALUES ($1, $2, $3) ON CONFLICT (workspace_id, name) DO UPDATE SET value = EXCLUDED.value",
|
||
&w_id,
|
||
name,
|
||
value
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspace.set_environment_variable",
|
||
ActionKind::Create,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Set environment variable {}", name))
|
||
}
|
||
None => {
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_env WHERE workspace_id = $1 AND name = $2",
|
||
&w_id,
|
||
name
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspace.delete_environment_variable",
|
||
ActionKind::Delete,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Deleted environment variable {}", name))
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct GetEncryptionKeyResponse {
|
||
key: String,
|
||
}
|
||
|
||
async fn get_encryption_key(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<GetEncryptionKeyResponse> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
windmill_api_auth::forbid_scoped_token_workspace_key(&authed)?;
|
||
|
||
audit_log(
|
||
&db,
|
||
&authed,
|
||
"workspaces.read_encryption_key",
|
||
ActionKind::Execute,
|
||
&w_id,
|
||
None,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
let encryption_key_opt = sqlx::query_scalar!(
|
||
"SELECT key FROM workspace_key WHERE workspace_id = $1",
|
||
w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?;
|
||
|
||
let encryption_key = not_found_if_none(encryption_key_opt, "workspace_encryption_key", w_id)?;
|
||
return Ok(Json(GetEncryptionKeyResponse { key: encryption_key }));
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct SetEncryptionKeyRequest {
|
||
new_key: String,
|
||
skip_reencrypt: Option<bool>,
|
||
}
|
||
|
||
async fn set_encryption_key(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(request): Json<SetEncryptionKeyRequest>,
|
||
) -> Result<()> {
|
||
require_super_admin(&db, &authed).await?;
|
||
windmill_api_auth::forbid_scoped_token_workspace_key(&authed)?;
|
||
|
||
if !WORKSPACE_KEY_REGEXP.is_match(request.new_key.as_str()) {
|
||
return Err(Error::BadRequest(
|
||
"Encryption key should be an alphanumeric string of 64 characters".to_string(),
|
||
));
|
||
}
|
||
|
||
// Build the previous cipher before the transaction (reads from cache/pool)
|
||
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// Under the row's lock, so two rotations racing serialize and each sees the key the
|
||
// other committed. The AI session backups in the workspace storage live under a prefix
|
||
// named by a generation this bumps (with the key, in this transaction) rather than
|
||
// being re-keyed; the older generations are deleted once this one has committed (see
|
||
// `ai_session_backups`). The same key set again is no rotation to them.
|
||
let previous_key: String = sqlx::query_scalar(
|
||
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud' FOR UPDATE",
|
||
)
|
||
.bind(&w_id)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"UPDATE workspace_key SET key = $1 WHERE workspace_id = $2",
|
||
request.new_key.clone(),
|
||
w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
let backups_generation: Option<i64> = if previous_key != request.new_key {
|
||
sqlx::query_scalar(
|
||
"UPDATE workspace_settings SET ai_sessions_backup_generation = \
|
||
ai_sessions_backup_generation + 1 WHERE workspace_id = $1 \
|
||
RETURNING ai_sessions_backup_generation",
|
||
)
|
||
.bind(&w_id)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let mut reencrypted_secret_paths: Vec<String> = Vec::new();
|
||
if !request.skip_reencrypt.unwrap_or(false) {
|
||
// Build the new cipher directly from the key string, since the transaction
|
||
// hasn't committed yet and build_crypt() would read the old key from the pool.
|
||
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
|
||
format!("{}{}", request.new_key, salt)
|
||
} else {
|
||
request.new_key.clone()
|
||
};
|
||
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
|
||
|
||
let mut truncated_new_key = request.new_key.clone();
|
||
truncated_new_key.truncate(8);
|
||
tracing::warn!(
|
||
"Re-encrypting all secrets for workspace {}. New key is {}***",
|
||
w_id,
|
||
truncated_new_key
|
||
);
|
||
|
||
let all_variables = sqlx::query!(
|
||
"SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
|
||
w_id
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
|
||
for variable in all_variables {
|
||
if !variable.is_secret {
|
||
continue;
|
||
}
|
||
let decrypted_value =
|
||
decrypt(&previous_encryption_key, variable.value).map_err(|e| {
|
||
Error::internal_err(format!(
|
||
"Error decrypting variable {}: {}",
|
||
variable.path, e
|
||
))
|
||
})?;
|
||
let new_encrypted_value = encrypt(&new_encryption_key, decrypted_value.as_str());
|
||
sqlx::query!(
|
||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||
new_encrypted_value,
|
||
w_id,
|
||
variable.path
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
reencrypted_secret_paths.push(variable.path);
|
||
}
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
// Invalidate the cache only after the transaction has committed
|
||
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
|
||
|
||
// Nothing writes under the older generations any more; the browsers push their
|
||
// sessions again under the new one.
|
||
#[cfg(feature = "parquet")]
|
||
if let Some(generation) = backups_generation {
|
||
crate::ai_session_backups::spawn_delete_older(db.clone(), w_id.clone(), generation);
|
||
}
|
||
#[cfg(not(feature = "parquet"))]
|
||
let _ = backups_generation;
|
||
|
||
// Build the batch: one event for the encryption key itself plus one per
|
||
// re-encrypted secret variable. The batch entrypoint dispatches a single
|
||
// git-sync job per repo carrying all items, so repos with Secrets sync
|
||
// enabled receive the new ciphertexts in one commit.
|
||
let mut batch: Vec<DeployedObject> = Vec::with_capacity(reencrypted_secret_paths.len() + 1);
|
||
batch.push(DeployedObject::Key { key_type: "encryption_key".to_string() });
|
||
for path in reencrypted_secret_paths {
|
||
batch.push(DeployedObject::Variable { path: path.clone(), parent_path: Some(path) });
|
||
}
|
||
|
||
handle_deployment_metadata_batch(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
batch,
|
||
Some("Encryption key updated".to_string()),
|
||
)
|
||
.await?;
|
||
|
||
return Ok(());
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct UsedTriggers {
|
||
pub websocket_used: bool,
|
||
pub http_routes_used: bool,
|
||
pub kafka_used: bool,
|
||
pub nats_used: bool,
|
||
pub postgres_used: bool,
|
||
pub mqtt_used: bool,
|
||
pub amqp_used: bool,
|
||
pub sqs_used: bool,
|
||
pub gcp_used: bool,
|
||
pub azure_used: bool,
|
||
pub email_used: bool,
|
||
pub nextcloud_used: bool,
|
||
pub google_used: bool,
|
||
pub github_used: bool,
|
||
}
|
||
|
||
async fn get_used_triggers(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<UsedTriggers> {
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let triggers_used = sqlx::query_as!(
|
||
UsedTriggers,
|
||
r#"
|
||
SELECT
|
||
EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS "websocket_used!",
|
||
EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS "http_routes_used!",
|
||
EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as "kafka_used!",
|
||
EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as "nats_used!",
|
||
EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS "postgres_used!",
|
||
EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!",
|
||
EXISTS(SELECT 1 FROM amqp_trigger WHERE workspace_id = $1) AS "amqp_used!",
|
||
EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!",
|
||
EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!",
|
||
EXISTS(SELECT 1 FROM azure_trigger WHERE workspace_id = $1) AS "azure_used!",
|
||
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!",
|
||
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!",
|
||
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS "google_used!",
|
||
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'github'::native_trigger_service) AS "github_used!"
|
||
"#,
|
||
w_id
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(Json(triggers_used))
|
||
}
|
||
|
||
async fn get_workspace_as_superadmin(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Workspace> {
|
||
require_super_admin(&db, &authed).await?;
|
||
let workspace = sqlx::query_as!(
|
||
Workspace,
|
||
"SELECT
|
||
workspace.id AS \"id!\",
|
||
workspace.name AS \"name!\",
|
||
workspace.owner AS \"owner!\",
|
||
workspace.deleted AS \"deleted!\",
|
||
workspace.premium AS \"premium!\",
|
||
workspace_settings.color AS \"color\",
|
||
workspace.parent_workspace_id AS \"parent_workspace_id\",
|
||
workspace.is_dev_workspace AS \"is_dev_workspace!\",
|
||
workspace.dev_workspace_label AS \"dev_workspace_label\"
|
||
FROM workspace
|
||
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
|
||
WHERE workspace.id = $1",
|
||
w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?;
|
||
|
||
let workspace = not_found_if_none(workspace, "workspace", w_id)?;
|
||
|
||
Ok(Json(workspace))
|
||
}
|
||
|
||
async fn list_workspaces_as_super_admin(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Query(pagination): Query<Pagination>,
|
||
) -> JsonResult<Vec<Workspace>> {
|
||
require_devops_role(&db, &authed).await?;
|
||
let (per_page, offset) = paginate(pagination);
|
||
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let workspaces = sqlx::query_as!(
|
||
Workspace,
|
||
"SELECT
|
||
workspace.id AS \"id!\",
|
||
workspace.name AS \"name!\",
|
||
workspace.owner AS \"owner!\",
|
||
workspace.deleted AS \"deleted!\",
|
||
workspace.premium AS \"premium!\",
|
||
workspace_settings.color AS \"color\",
|
||
workspace.parent_workspace_id AS \"parent_workspace_id\",
|
||
workspace.is_dev_workspace AS \"is_dev_workspace!\",
|
||
workspace.dev_workspace_label AS \"dev_workspace_label\"
|
||
FROM workspace
|
||
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
|
||
LIMIT $1 OFFSET $2",
|
||
per_page as i32,
|
||
offset as i32
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(Json(workspaces))
|
||
}
|
||
|
||
async fn user_workspaces(
|
||
Extension(db): Extension<DB>,
|
||
ApiAuthed { email, .. }: ApiAuthed,
|
||
) -> JsonResult<WorkspaceList> {
|
||
let mut tx = db.begin().await?;
|
||
let workspaces = sqlx::query_as!(
|
||
UserWorkspace,
|
||
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
|
||
workspace.is_dev_workspace, workspace.dev_workspace_label,
|
||
workspace.owner AS \"created_by?\",
|
||
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
|
||
usr.disabled
|
||
FROM workspace
|
||
JOIN usr ON usr.workspace_id = workspace.id
|
||
JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id
|
||
WHERE usr.email = $1 AND workspace.deleted = false",
|
||
email
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(Json(WorkspaceList { email, workspaces }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct SessionWorkspaceStatusRequest {
|
||
workspace_ids: Vec<String>,
|
||
}
|
||
|
||
/// `ai_config.sessions_retention_days` as stored, `None` when unset or not a count of days.
|
||
pub fn sessions_retention_days(value: Option<&serde_json::Value>) -> Option<u32> {
|
||
value
|
||
.and_then(|v| v.as_u64())
|
||
.filter(|days| *days >= 1)
|
||
.and_then(|days| u32::try_from(days).ok())
|
||
}
|
||
|
||
/// Reconciliation support for client-side AI sessions, which the backend cannot touch
|
||
/// directly. The client posts the workspace ids its sessions reference and uses the
|
||
/// per-id status to keep sessions in sync with workspace lifecycle: `deleted` (no row, or
|
||
/// no way for this caller to reach it) drops the sessions, `archived` (soft-deleted, still
|
||
/// reachable) archives them, `active` restores ones previously archived-by-workspace.
|
||
/// Archived and hard-deleted workspaces are absent from `user_workspaces`, so this is the
|
||
/// only way the client learns about a change made while it was away or on another device.
|
||
///
|
||
/// Membership alone under-reports reachability: a superadmin is authed into any existing
|
||
/// workspace without a `usr` row, and `admins` has no `usr` rows at all, so answering from
|
||
/// `usr` destroys sessions that still work. It over-reports in one direction — a `usr` row
|
||
/// with `disabled` counts here but not in the extractor — which only leaves a session
|
||
/// lingering, so it is deliberately not treated as unreachable.
|
||
async fn session_workspace_status(
|
||
Extension(db): Extension<DB>,
|
||
authed: ApiAuthed,
|
||
Json(req): Json<SessionWorkspaceStatusRequest>,
|
||
) -> JsonResult<HashMap<String, String>> {
|
||
if req.workspace_ids.len() > 1000 {
|
||
return Err(Error::BadRequest(
|
||
"Too many workspace ids (max 1000)".to_string(),
|
||
));
|
||
}
|
||
let email = &authed.email;
|
||
let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
|
||
let rows = sqlx::query!(
|
||
// A missing workspace row must be caught before the membership arm: for a
|
||
// superadmin the two arms below both fall through, and a hard-deleted workspace
|
||
// would report `active` forever.
|
||
"SELECT req.id AS \"id!\",
|
||
(CASE
|
||
WHEN workspace.id IS NULL THEN 'deleted'
|
||
WHEN usr.email IS NULL AND NOT $3 THEN 'deleted'
|
||
WHEN workspace.deleted THEN 'archived'
|
||
ELSE 'active'
|
||
END) AS \"status!\"
|
||
FROM unnest($1::text[]) AS req(id)
|
||
LEFT JOIN workspace ON workspace.id = req.id
|
||
LEFT JOIN usr ON usr.workspace_id = workspace.id AND usr.email = $2",
|
||
&req.workspace_ids[..],
|
||
email,
|
||
is_superadmin,
|
||
)
|
||
.fetch_all(&db)
|
||
.await?;
|
||
let statuses = rows.into_iter().map(|r| (r.id, r.status)).collect();
|
||
Ok(Json(statuses))
|
||
}
|
||
|
||
/// The AI session retention a browser deletes its local copies by (docs/ai-session-backups.md).
|
||
/// Its own route, not a field on the status above, whose shape an older tab still reads. Unlike
|
||
/// a status, it answers only for a workspace this caller can be authed into: a setting is the
|
||
/// workspace's to tell, so a disabled membership gets none though its sessions still reconcile.
|
||
async fn session_workspace_retention(
|
||
Extension(db): Extension<DB>,
|
||
authed: ApiAuthed,
|
||
Json(req): Json<SessionWorkspaceStatusRequest>,
|
||
) -> JsonResult<HashMap<String, u32>> {
|
||
if req.workspace_ids.len() > 1000 {
|
||
return Err(Error::BadRequest(
|
||
"Too many workspace ids (max 1000)".to_string(),
|
||
));
|
||
}
|
||
let email = &authed.email;
|
||
let is_superadmin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
|
||
let rows = sqlx::query!(
|
||
"SELECT workspace_settings.workspace_id AS \"id!\",
|
||
workspace_settings.ai_config->'sessions_retention_days' AS retention
|
||
FROM workspace_settings
|
||
LEFT JOIN usr ON usr.workspace_id = workspace_settings.workspace_id AND usr.email = $2
|
||
WHERE workspace_settings.workspace_id = ANY($1)
|
||
AND ($3 OR (usr.email IS NOT NULL AND NOT usr.disabled))",
|
||
&req.workspace_ids[..],
|
||
email,
|
||
is_superadmin,
|
||
)
|
||
.fetch_all(&db)
|
||
.await?;
|
||
let days = rows
|
||
.into_iter()
|
||
.filter_map(|r| sessions_retention_days(r.retention.as_ref()).map(|days| (r.id, days)))
|
||
.collect();
|
||
Ok(Json(days))
|
||
}
|
||
|
||
/// The instance critical alert channels belong to the instance operator, who on cloud is
|
||
/// not the workspace owner and never opted into a tenant's job failures. Fork workspaces run
|
||
/// throwaway copies of their parent's runnables, so instance-wide operational alerting must
|
||
/// stay a property of the real workspace.
|
||
async fn ensure_instance_alert_fallback_allowed<'c>(
|
||
tx: &mut Transaction<'c, Postgres>,
|
||
w_id: &str,
|
||
) -> Result<()> {
|
||
if *CLOUD_HOSTED {
|
||
return Err(Error::BadRequest(
|
||
"Reporting to the instance critical alert channels is not available on cloud"
|
||
.to_string(),
|
||
));
|
||
}
|
||
let is_fork = sqlx::query_scalar!(
|
||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!" FROM workspace WHERE id = $1"#,
|
||
w_id
|
||
)
|
||
.fetch_optional(&mut **tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if is_fork {
|
||
return Err(Error::BadRequest(
|
||
"Reporting to the instance critical alert channels cannot be enabled on a fork workspace"
|
||
.to_string(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn check_w_id_conflict<'c>(tx: &mut Transaction<'c, Postgres>, w_id: &str) -> Result<()> {
|
||
if w_id == "global" {
|
||
return Err(windmill_common::error::Error::BadRequest(
|
||
"'global' is not allowed as a workspace ID".to_string(),
|
||
));
|
||
}
|
||
let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1)", w_id)
|
||
.fetch_one(&mut **tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if exists {
|
||
return Err(windmill_common::error::Error::BadRequest(format!(
|
||
"Workspace {} already exists",
|
||
w_id
|
||
)));
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
/// Reject fork creation when the target workspace id is already taken,
|
||
/// distinguishing archived workspaces: archiving is a soft delete that keeps
|
||
/// the id reserved, which users frequently mistake for a permanent delete.
|
||
async fn check_fork_w_id_conflict(db: &DB, w_id: &str) -> Result<()> {
|
||
let deleted = sqlx::query_scalar!("SELECT deleted FROM workspace WHERE id = $1", w_id)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
match deleted {
|
||
Some(true) => Err(Error::BadRequest(format!(
|
||
"Workspace '{w_id}' already exists but is archived (archiving does not free up the workspace id). \
|
||
To reuse this id, permanently delete the archived workspace first — its owner or a superadmin \
|
||
can do so from the fork creation dialog, the superadmin workspaces page, or the CLI \
|
||
(`wmill workspace delete-fork`) — or choose a different fork id."
|
||
))),
|
||
Some(false) => Err(Error::BadRequest(format!(
|
||
"Workspace '{w_id}' already exists. Delete the existing fork first or choose a different fork id."
|
||
))),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// A fork id is reusable: it is freed when a fork is deleted and can be claimed
|
||
/// again under the same name. `workspace_diff`, `workspace_diff_full_scan` and
|
||
/// `skip_workspace_diff_tally` are keyed by workspace id with no FK cascade, so a
|
||
/// freshly created fork could inherit cached diff state from a previous occupant
|
||
/// of its id — a stale skip row suppresses comparison entirely, a stale full-scan
|
||
/// row makes a never-scanned pair report as scanned and up to date, and stale
|
||
/// workspace_diff rows produce a spurious "changes not visible" warning that hides
|
||
/// the deploy button. Clear all three so a new fork always starts with clean diff
|
||
/// state, regardless of how the id was freed.
|
||
async fn purge_stale_fork_diff_state(db: &DB, fork_id: &str) -> Result<()> {
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff WHERE source_workspace_id = $1 OR fork_workspace_id = $1",
|
||
fork_id
|
||
)
|
||
.execute(db)
|
||
.await?;
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff_full_scan WHERE source_workspace_id = $1 OR fork_workspace_id = $1",
|
||
fork_id
|
||
)
|
||
.execute(db)
|
||
.await?;
|
||
sqlx::query!(
|
||
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id = $1",
|
||
fork_id
|
||
)
|
||
.execute(db)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
lazy_static::lazy_static! {
|
||
|
||
pub static ref CREATE_WORKSPACE_REQUIRE_SUPERADMIN: bool = {
|
||
match std::env::var("CREATE_WORKSPACE_REQUIRE_SUPERADMIN") {
|
||
Ok(val) => val == "true",
|
||
Err(_) => true,
|
||
}
|
||
};
|
||
|
||
pub static ref DISABLE_WORKSPACE_FORK: bool = {
|
||
match std::env::var("DISABLE_WORKSPACE_FORK") {
|
||
Ok(val) => val == "true",
|
||
Err(_) => false,
|
||
}
|
||
};
|
||
|
||
// Cloud only: how many forks a premium workspace may have per paid (developer) seat.
|
||
pub static ref MAX_FORKS_PER_SEAT: i64 = std::env::var("MAX_FORKS_PER_SEAT")
|
||
.ok()
|
||
.and_then(|v| v.parse::<i64>().ok())
|
||
.filter(|v| *v >= 0)
|
||
.unwrap_or(5);
|
||
|
||
// How deep a fork chain may nest (root = depth 0, a direct fork = depth 1). A general guardrail
|
||
// for all builds, independent of the cloud per-seat cap: deep fork chains are a footgun and no
|
||
// real use case needs them. Clamped to [1, 20] so it's always a real limit and can never exceed
|
||
// the fork-walk recursion backstop (20) that billing/count resolution uses (a chain deeper than
|
||
// the backstop would truncate and mis-resolve its root).
|
||
pub static ref MAX_FORK_DEPTH: i64 = std::env::var("MAX_FORK_DEPTH")
|
||
.ok()
|
||
.and_then(|v| v.parse::<i64>().ok())
|
||
.map(|v| v.clamp(1, 20))
|
||
.unwrap_or(5);
|
||
|
||
}
|
||
|
||
async fn create_workspace_require_superadmin() -> String {
|
||
format!("{}", *CREATE_WORKSPACE_REQUIRE_SUPERADMIN)
|
||
}
|
||
|
||
async fn _check_nb_of_workspaces(db: &DB) -> Result<()> {
|
||
let nb_workspaces = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = false",
|
||
)
|
||
.fetch_one(db)
|
||
.await?;
|
||
if nb_workspaces.unwrap_or(0) >= 2 {
|
||
return Err(Error::BadRequest(
|
||
"You have reached the maximum number of workspaces (2 outside of default workspace 'admins') without an enterprise license. Archive/delete another workspace to create a new one"
|
||
.to_string(),
|
||
));
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
async fn _check_nb_of_archived_workspaces(db: &DB) -> Result<()> {
|
||
let nb_archived = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = true",
|
||
)
|
||
.fetch_one(db)
|
||
.await?;
|
||
if nb_archived.unwrap_or(0) >= 1 {
|
||
return Err(Error::BadRequest(
|
||
"You have reached the maximum number of archived workspaces (1) without an enterprise license. Permanently delete or unarchive the existing archived workspace first"
|
||
.to_string(),
|
||
));
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
async fn create_workspace(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Json(nw): Json<CreateWorkspace>,
|
||
) -> Result<String> {
|
||
if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN {
|
||
require_super_admin(&db, &authed).await?;
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
_check_nb_of_workspaces(&db).await?;
|
||
|
||
if *CLOUD_HOSTED {
|
||
let nb_workspaces = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM workspace WHERE owner = $1",
|
||
authed.email
|
||
)
|
||
.fetch_one(&db)
|
||
.await?;
|
||
if nb_workspaces.unwrap_or(0) >= 10 {
|
||
return Err(Error::BadRequest(
|
||
"You have reached the maximum number of workspaces (10) on cloud. Contact support@windmill.dev to increase the limit"
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
validate_workspace_name(&nw.name)?;
|
||
|
||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||
|
||
check_w_id_conflict(&mut tx, &nw.id).await?;
|
||
sqlx::query!(
|
||
"INSERT INTO workspace
|
||
(id, name, owner)
|
||
VALUES ($1, $2, $3)",
|
||
nw.id,
|
||
nw.name,
|
||
authed.email,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
if nw.error_handler_fallback_to_instance_alerts {
|
||
ensure_instance_alert_fallback_allowed(&mut tx, &nw.id).await?;
|
||
}
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_settings
|
||
(workspace_id, color, error_handler_fallback_to_instance_alerts)
|
||
VALUES ($1, $2, $3)",
|
||
nw.id,
|
||
nw.color,
|
||
nw.error_handler_fallback_to_instance_alerts,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
let key = rd_string(64);
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_key
|
||
(workspace_id, kind, key)
|
||
VALUES ($1, 'cloud', $2)",
|
||
nw.id,
|
||
&key
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// let mc = magic_crypt::new_magic_crypt!(key, 256);
|
||
// sqlx::query!(
|
||
// "INSERT INTO variable
|
||
// (workspace_id, path, value, is_secret, description)
|
||
// VALUES ($1, 'g/all/pretty_secret', $2, true, 'This item is secret'),
|
||
// ($3, 'g/all/not_secret', $4, false, 'This item is not secret')",
|
||
// nw.id,
|
||
// crate::variables::encrypt(&mc, "pretty secret value"),
|
||
// nw.id,
|
||
// "finland does not actually exist",
|
||
// )
|
||
// .execute(&mut *tx)
|
||
// .await?;
|
||
|
||
let automate_username_creation = sqlx::query_scalar!(
|
||
"SELECT value FROM global_settings WHERE name = $1",
|
||
AUTOMATE_USERNAME_CREATION_SETTING,
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.map(|v| v.as_bool())
|
||
.flatten()
|
||
.unwrap_or(true);
|
||
|
||
let username = if automate_username_creation {
|
||
if nw.username.is_some() && nw.username.unwrap().len() > 0 {
|
||
return Err(Error::BadRequest(
|
||
"username is not allowed when username creation is automated".to_string(),
|
||
));
|
||
}
|
||
get_instance_username_or_create_pending(&mut tx, &authed.email).await?
|
||
} else {
|
||
nw.username
|
||
.ok_or(Error::BadRequest("username is required".to_string()))?
|
||
};
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO usr
|
||
(workspace_id, email, username, is_admin)
|
||
VALUES ($1, $2, $3, true)",
|
||
nw.id,
|
||
authed.email,
|
||
username,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO group_
|
||
VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
|
||
nw.id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO group_
|
||
VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')",
|
||
nw.id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO usr_to_group
|
||
VALUES ($1, 'all', $2)",
|
||
nw.id,
|
||
username
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.create",
|
||
ActionKind::Create,
|
||
&nw.id,
|
||
Some(nw.name.as_str()),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(format!("Created workspace {}", &nw.id))
|
||
}
|
||
|
||
// `authed` is the forker — `clone_drafts` only carries this user's per-user
|
||
// drafts (and the legacy NULL-email workspace draft, if any) across, since other
|
||
// users aren't added to the fork's `usr` table and their drafts would dangle as
|
||
// orphans.
|
||
async fn clone_workspace_data(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
authed: &ApiAuthed,
|
||
) -> Result<()> {
|
||
// Clone workspace settings (merge with existing basic settings)
|
||
update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone data table migration definitions (the settings above carry the data
|
||
// table config; this carries their migration history).
|
||
crate::datatable_migrations::clone_datatable_migrations(
|
||
tx,
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.await?;
|
||
|
||
// Clone workspace environment variables
|
||
clone_workspace_env(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone folders
|
||
clone_folders(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone groups
|
||
clone_groups(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone resource types
|
||
clone_resource_types(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone resources
|
||
clone_resources(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone variables (including external secret backend replication)
|
||
clone_variables(tx, db, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone scripts with new hashes
|
||
clone_scripts(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
clone_eval_datasets(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone the dbt graph sidecars. After `clone_scripts`, which keeps each
|
||
// script's hash: these key on it, and a static descriptor never re-ingests,
|
||
// so a fork without them shows dbt scripts with no models until someone
|
||
// redeploys.
|
||
clone_dbt_graph(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone CI test references
|
||
clone_ci_test_references(tx, source_workspace_id, target_workspace_id).await?;
|
||
clone_macro_registry(tx, source_workspace_id, target_workspace_id).await?;
|
||
clone_metric_catalog(tx, source_workspace_id, target_workspace_id).await?;
|
||
clone_asset_usages_and_triggers(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone flows with new versions
|
||
clone_flows(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone flow nodes
|
||
clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone apps with new IDs and app scripts
|
||
let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?;
|
||
|
||
// Clone raw apps
|
||
clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone the forker's own per-user drafts (plus the legacy NULL-email
|
||
// workspace draft, if any) so they keep their pending edits in the
|
||
// fork. Other users' drafts are intentionally NOT cloned — they don't
|
||
// own a `usr` row in the fork (see `clone_workspace_full`), so those
|
||
// drafts would belong to someone the fork holds no membership for.
|
||
clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?;
|
||
|
||
// Clone workspace runnable dependencies and dependency map
|
||
clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?;
|
||
|
||
// Clone workspace dependencies
|
||
clone_workspace_dependencies(tx, source_workspace_id, target_workspace_id).await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Clone every trigger and schedule from the parent workspace, forcing
|
||
/// `mode='disabled'` / `enabled=false`. Always runs at fork creation —
|
||
/// disabled rows have no side effects, so cloning them is safe and lets
|
||
/// users re-enable selectively in the fork. Listener identifiers
|
||
/// (group_id, replication_slot_name, subscription_name, …) are copied
|
||
/// verbatim — the runtime suffix that prevents the fork from competing with
|
||
/// the parent ships in a follow-up PR.
|
||
async fn clone_triggers_and_schedules(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// Managed ducklake-maintenance schedules are excluded: the fork starts
|
||
// with no ducklake config, so cloned rows could never resolve a lake and
|
||
// the schedule API refuses mutations under the reserved prefix.
|
||
sqlx::query!(
|
||
r#"INSERT INTO schedule (
|
||
workspace_id, path, edited_by, edited_at, schedule, enabled, script_path,
|
||
args, extra_perms, is_flow, email, error, timezone, on_failure,
|
||
on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args,
|
||
on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry,
|
||
summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args,
|
||
cron_version, description, dynamic_skip, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
$1, path, edited_by, edited_at, schedule, FALSE, script_path,
|
||
args, extra_perms, is_flow, email, error, timezone, on_failure,
|
||
on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args,
|
||
on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry,
|
||
summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args,
|
||
cron_version, description, dynamic_skip, permissioned_as, labels
|
||
FROM schedule WHERE workspace_id = $2 AND NOT starts_with(path, $3)"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
windmill_common::workspaces::DUCKLAKE_MAINTENANCE_PATH_PREFIX,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
// Skip non-workspaced HTTP triggers: their URL has no workspace prefix, so
|
||
// a clone would collide with the parent's row at runtime (matchit::Router
|
||
// silently drops one of two duplicates) and `route_path_key_exists` would
|
||
// also fail to spot the cross-workspace conflict cleanly. The instance
|
||
// settings `CLOUD_HOSTED` and `HTTP_ROUTE_WORKSPACED_ROUTE` force every
|
||
// route to be workspace-prefixed regardless of the column, so when either
|
||
// is on we clone everything.
|
||
let force_workspaced =
|
||
*CLOUD_HOSTED || HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
|
||
sqlx::query!(
|
||
r#"INSERT INTO http_trigger (
|
||
path, route_path, route_path_key, script_path, is_flow, workspace_id,
|
||
edited_by, edited_at, extra_perms, authentication_method, http_method,
|
||
static_asset_config, is_static_website, workspaced_route, wrap_body,
|
||
raw_string, allowed_origins, authentication_resource_path, summary, description,
|
||
error_handler_path, error_handler_args, retry, request_type, mode,
|
||
permissioned_as, labels
|
||
)
|
||
SELECT
|
||
path, route_path, route_path_key, script_path, is_flow, $1,
|
||
edited_by, edited_at, extra_perms, authentication_method, http_method,
|
||
static_asset_config, is_static_website, workspaced_route, wrap_body,
|
||
raw_string, allowed_origins, authentication_resource_path, summary, description,
|
||
error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE,
|
||
permissioned_as, labels
|
||
FROM http_trigger
|
||
WHERE workspace_id = $2
|
||
AND (workspaced_route IS TRUE OR $3)"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
force_workspaced,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO websocket_trigger (
|
||
path, url, script_path, is_flow, workspace_id, edited_by, edited_at,
|
||
extra_perms, server_id, last_server_ping, error, filters, initial_messages,
|
||
url_runnable_args, can_return_message, error_handler_path, error_handler_args,
|
||
retry, can_return_error_result, mode, permissioned_as, filter_logic, labels,
|
||
heartbeat
|
||
)
|
||
SELECT
|
||
path, url, script_path, is_flow, $1, edited_by, edited_at,
|
||
extra_perms, NULL, NULL, NULL, filters, initial_messages,
|
||
url_runnable_args, can_return_message, error_handler_path, error_handler_args,
|
||
retry, can_return_error_result, 'disabled'::TRIGGER_MODE, permissioned_as, filter_logic, labels,
|
||
heartbeat
|
||
FROM websocket_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO kafka_trigger (
|
||
path, kafka_resource_path, topics, group_id, script_path, is_flow,
|
||
workspace_id, edited_by, edited_at, extra_perms, server_id,
|
||
last_server_ping, error, error_handler_path, error_handler_args, retry,
|
||
mode, filters, auto_offset_reset, reset_offset, auto_commit,
|
||
permissioned_as, filter_logic, labels
|
||
)
|
||
SELECT
|
||
path, kafka_resource_path, topics, group_id, script_path, is_flow,
|
||
$1, edited_by, edited_at, extra_perms, NULL,
|
||
NULL, NULL, error_handler_path, error_handler_args, retry,
|
||
'disabled'::TRIGGER_MODE, filters, auto_offset_reset, reset_offset, auto_commit,
|
||
permissioned_as, filter_logic, labels
|
||
FROM kafka_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO nats_trigger (
|
||
path, nats_resource_path, subjects, stream_name, consumer_name,
|
||
use_jetstream, script_path, is_flow, workspace_id, edited_by, edited_at,
|
||
extra_perms, server_id, last_server_ping, error, error_handler_path,
|
||
error_handler_args, retry, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
path, nats_resource_path, subjects, stream_name, consumer_name,
|
||
use_jetstream, script_path, is_flow, $1, edited_by, edited_at,
|
||
extra_perms, NULL, NULL, NULL, error_handler_path,
|
||
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM nats_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO postgres_trigger (
|
||
path, script_path, is_flow, workspace_id, edited_by, edited_at,
|
||
extra_perms, postgres_resource_path, error, server_id, last_server_ping,
|
||
replication_slot_name, publication_name, error_handler_path,
|
||
error_handler_args, retry, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
path, script_path, is_flow, $1, edited_by, edited_at,
|
||
extra_perms, postgres_resource_path, NULL, NULL, NULL,
|
||
replication_slot_name, publication_name, error_handler_path,
|
||
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM postgres_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO mqtt_trigger (
|
||
mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config,
|
||
client_id, path, script_path, is_flow, workspace_id, edited_by, edited_at,
|
||
extra_perms, server_id, last_server_ping, error, error_handler_path,
|
||
error_handler_args, retry, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config,
|
||
client_id, path, script_path, is_flow, $1, edited_by, edited_at,
|
||
extra_perms, NULL, NULL, NULL, error_handler_path,
|
||
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM mqtt_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO amqp_trigger (
|
||
amqp_resource_path, queue_name, exchange, options, path, script_path, is_flow,
|
||
workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping,
|
||
error, error_handler_path, error_handler_args, retry, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
amqp_resource_path, queue_name, exchange, options, path, script_path, is_flow,
|
||
$1, edited_by, edited_at, extra_perms, NULL, NULL,
|
||
NULL, error_handler_path, error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM amqp_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO sqs_trigger (
|
||
path, queue_url, aws_resource_path, message_attributes, script_path,
|
||
is_flow, workspace_id, edited_by, edited_at, extra_perms, error,
|
||
server_id, last_server_ping, aws_auth_resource_type, error_handler_path,
|
||
error_handler_args, retry, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
path, queue_url, aws_resource_path, message_attributes, script_path,
|
||
is_flow, $1, edited_by, edited_at, extra_perms, NULL,
|
||
NULL, NULL, aws_auth_resource_type, error_handler_path,
|
||
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM sqs_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO gcp_trigger (
|
||
gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,
|
||
delivery_config, path, script_path, is_flow, workspace_id, edited_by,
|
||
edited_at, extra_perms, server_id, last_server_ping, error,
|
||
subscription_mode, error_handler_path, error_handler_args, retry,
|
||
auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,
|
||
delivery_config, path, script_path, is_flow, $1, edited_by,
|
||
edited_at, extra_perms, NULL, NULL, NULL,
|
||
subscription_mode, error_handler_path, error_handler_args, retry,
|
||
auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM gcp_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"INSERT INTO azure_trigger (
|
||
azure_resource_path, azure_mode, scope_resource_id, topic_name,
|
||
subscription_name, event_type_filters, push_auth_config, path, script_path,
|
||
is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id,
|
||
last_server_ping, error, mode, permissioned_as, error_handler_path,
|
||
error_handler_args, retry, labels
|
||
)
|
||
SELECT
|
||
azure_resource_path, azure_mode, scope_resource_id, topic_name,
|
||
subscription_name, event_type_filters, push_auth_config, path, script_path,
|
||
is_flow, $1, edited_by, edited_at, extra_perms, NULL,
|
||
NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path,
|
||
error_handler_args, retry, labels
|
||
FROM azure_trigger WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
// Skip non-workspaced email triggers: same shape as the non-workspaced
|
||
// HTTP route case — a clone would share the same `local_part@domain`
|
||
// address as the parent, and incoming mail would arbitrarily land in one
|
||
// or the other. CLOUD_HOSTED scopes email lookup by workspace_id natively,
|
||
// so on cloud we clone everything.
|
||
sqlx::query!(
|
||
r#"INSERT INTO email_trigger (
|
||
path, local_part, workspaced_local_part, script_path, is_flow,
|
||
workspace_id, edited_by, edited_at, extra_perms, error_handler_path,
|
||
error_handler_args, retry, mode, permissioned_as, labels
|
||
)
|
||
SELECT
|
||
path, local_part, workspaced_local_part, script_path, is_flow,
|
||
$1, edited_by, edited_at, extra_perms, error_handler_path,
|
||
error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels
|
||
FROM email_trigger
|
||
WHERE workspace_id = $2
|
||
AND (workspaced_local_part IS TRUE OR $3)"#,
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
*CLOUD_HOSTED,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn update_workspace_settings(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_key (workspace_id, kind, key)
|
||
SELECT $2, kind, key FROM workspace_key WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
r#"
|
||
UPDATE workspace_settings
|
||
SET
|
||
ai_config = source_ws.ai_config,
|
||
large_file_storage = source_ws.large_file_storage,
|
||
ducklake = source_ws.ducklake,
|
||
dbt_warehouses = source_ws.dbt_warehouses,
|
||
datatable = source_ws.datatable,
|
||
git_app_installations = source_ws.git_app_installations
|
||
FROM workspace_settings source_ws
|
||
WHERE source_ws.workspace_id = $1
|
||
AND workspace_settings.workspace_id = $2
|
||
"#,
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
let current_git_sync_settings = sqlx::query!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
|
||
source_workspace_id
|
||
)
|
||
.fetch_optional(&mut **tx)
|
||
.await?;
|
||
|
||
let mut git_sync_settings = if let Some(row) = current_git_sync_settings {
|
||
if let Some(git_sync) = row.git_sync {
|
||
serde_json::from_value::<WorkspaceGitSyncSettings>(git_sync)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?
|
||
} else {
|
||
WorkspaceGitSyncSettings::default()
|
||
}
|
||
} else {
|
||
WorkspaceGitSyncSettings::default()
|
||
};
|
||
|
||
// We only keep the first git sync repo that is sync mode (use_individual_branch = false), since it is considered the main one
|
||
// Context: see WIN-1559
|
||
git_sync_settings.repositories = git_sync_settings
|
||
.repositories
|
||
.into_iter()
|
||
.filter(|r| !r.use_individual_branch.unwrap_or(false))
|
||
.take(1)
|
||
.map(|mut r| {
|
||
// Auto-pull and fork PRs are parent-owned and must not be inherited:
|
||
// the fork would otherwise carry the parent's webhook id (turning off
|
||
// auto-pull on the fork would delete the parent's webhook). A fork
|
||
// still inherits the push-direction config, the installation, and the
|
||
// recorded credential status, which describes the repository rather
|
||
// than belonging to either workspace and would otherwise leave the
|
||
// fork unqualified for managed features until its first check.
|
||
r.auto_pull = None;
|
||
r.fork_open_prs = false;
|
||
r.open_pr_error = None;
|
||
r
|
||
})
|
||
.collect();
|
||
|
||
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
|
||
.map_err(|err| Error::internal_err(err.to_string()))?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
||
serialized_config,
|
||
target_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_workspace_env(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_env (workspace_id, name, value)
|
||
SELECT $2, name, value
|
||
FROM workspace_env
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_folders(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels)
|
||
SELECT $2, name, display_name, owners, extra_perms, summary, edited_at, created_by, default_permissioned_as, labels
|
||
FROM folder
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_groups(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO group_ (workspace_id, name, summary, extra_perms)
|
||
SELECT $2, name, summary, extra_perms
|
||
FROM group_
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO usr_to_group (workspace_id, group_, usr)
|
||
SELECT $2, group_, usr
|
||
FROM usr_to_group
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Copy the source workspace's members (the `usr` rows, carrying each member's role) into the
|
||
/// target so a fork/dev can be a shared environment. Idempotent — skips members the target already
|
||
/// has. Group memberships are not handled here: the sole caller is the create-fork path, where
|
||
/// `clone_groups` already copies the source's full group structure (including `all` membership).
|
||
async fn copy_workspace_members(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)
|
||
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via
|
||
FROM usr WHERE workspace_id = $2
|
||
ON CONFLICT DO NOTHING",
|
||
target_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_resource_types(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name)
|
||
SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name
|
||
FROM resource_type
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_resources(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)
|
||
SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by
|
||
FROM resource
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_eval_datasets(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// The authored evaluation data — datasets and their cases — travels with a fork like resources
|
||
// and scripts do; the runs (experiments) do not, since they name jobs the fork has no copy of.
|
||
sqlx::query!(
|
||
"INSERT INTO eval_dataset (workspace_id, path, summary, scorers, extra_perms, created_at, created_by, edited_at, edited_by)
|
||
SELECT $2, path, summary, scorers, extra_perms, created_at, created_by, edited_at, edited_by
|
||
FROM eval_dataset WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
// A new id per cloned case: `eval_case`'s primary key is the id alone, unique across the whole
|
||
// table, so copying it would collide with the source's own rows.
|
||
sqlx::query!(
|
||
"INSERT INTO eval_case (workspace_id, dataset_path, input, expected, created_at, created_by)
|
||
SELECT $2, dataset_path, input, expected, created_at, created_by
|
||
FROM eval_case WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_variables(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)
|
||
SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at
|
||
FROM variable
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
// With an external backend the secret lives in the store under (workspace_id,
|
||
// path), so the row copy above leaves the fork pointing at keys that don't
|
||
// exist. Replicate every secret, not just marker-valued ones: migration writes
|
||
// to the store without rewriting `value` to a `$...:` marker.
|
||
if is_vault_backend_configured(db).await? {
|
||
let secret_variables = sqlx::query!(
|
||
"SELECT path FROM variable
|
||
WHERE workspace_id = $1 AND is_secret = true AND value != ''",
|
||
target_workspace_id,
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
|
||
let backend = get_secret_backend(db).await?;
|
||
for variable in secret_variables {
|
||
match backend
|
||
.get_secret(source_workspace_id, &variable.path)
|
||
.await
|
||
{
|
||
Ok(plain_value) => {
|
||
backend
|
||
.set_secret(target_workspace_id, &variable.path, &plain_value)
|
||
.await
|
||
.map_err(|e| {
|
||
Error::internal_err(format!(
|
||
"Failed to replicate secret variable {} to the external secret backend for the forked workspace: {e}",
|
||
variable.path
|
||
))
|
||
})?;
|
||
}
|
||
// The source secret is unreadable (e.g. deleted out-of-band from
|
||
// the external store), so the variable is equally broken in the
|
||
// source workspace — don't let it block forking.
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
workspace_id = %source_workspace_id,
|
||
path = %variable.path,
|
||
error = %e,
|
||
"Could not read secret variable from the external secret backend while forking; the forked variable will not resolve"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// A principal is workspace-scoped — `usr` is keyed by `(workspace_id, username)` — and a
|
||
/// clone lands in a workspace with its own membership, so it is kept only when it still
|
||
/// resolves there (the fork copies usernames and groups verbatim, and an instance superadmin
|
||
/// resolves anywhere). Dropping one that names nobody is the only safe alternative: it could
|
||
/// not authenticate, and the runnable falls back to running as its caller.
|
||
async fn clone_scripts(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// Clone all scripts directly with a single query
|
||
sqlx::query!(
|
||
r#"INSERT INTO script (
|
||
workspace_id, hash, path, parent_hashes, summary, description, content,
|
||
created_by, created_at, archived, schema, deleted, is_template,
|
||
extra_perms, lock, lock_error_logs, language, kind, tag,
|
||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||
visible_to_runner_only, auto_kind, codebase, has_preprocessor,
|
||
on_behalf_of, on_behalf_of_email, assets, modules
|
||
)
|
||
SELECT
|
||
$1, hash, path, parent_hashes, summary, description, content,
|
||
created_by, created_at, archived, schema, deleted, is_template,
|
||
extra_perms, lock, lock_error_logs, language, kind, tag,
|
||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||
visible_to_runner_only, auto_kind, codebase, has_preprocessor,
|
||
-- Same three forms and the same prefix-first rule as permissioned_as_exists,
|
||
-- superadmin fallback included: one acting outside their workspaces has no usr
|
||
-- row but still authenticates.
|
||
CASE WHEN on_behalf_of LIKE 'u/%' THEN
|
||
(SELECT on_behalf_of WHERE EXISTS (
|
||
SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar
|
||
AND u.username = substring(on_behalf_of from 3)
|
||
UNION ALL
|
||
SELECT 1 FROM password p WHERE p.super_admin
|
||
AND (p.username = substring(on_behalf_of from 3)
|
||
OR p.email = substring(on_behalf_of from 3))))
|
||
WHEN on_behalf_of LIKE 'g/%' THEN
|
||
(SELECT on_behalf_of WHERE EXISTS (
|
||
SELECT 1 FROM group_ g WHERE g.workspace_id = $1::varchar
|
||
AND g.name = substring(on_behalf_of from 3)))
|
||
ELSE
|
||
(SELECT on_behalf_of WHERE EXISTS (
|
||
SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar
|
||
AND u.username = on_behalf_of
|
||
UNION ALL
|
||
SELECT 1 FROM password p WHERE p.email = on_behalf_of
|
||
AND p.super_admin))
|
||
END, on_behalf_of_email, assets, modules
|
||
FROM script
|
||
WHERE workspace_id = $2"#,
|
||
target_workspace_id,
|
||
source_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
clear_orphaned_compat_address(
|
||
tx,
|
||
"script",
|
||
"hash",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// The parsed dbt graph a deployed script carries: its models, their SQL and
|
||
/// tests, and the `ref()` and column-level lineage between them.
|
||
///
|
||
/// Keyed on (workspace_id, script_path, script_hash), and the fork keeps every
|
||
/// script's hash, so each row moves across as itself.
|
||
///
|
||
/// The DEPLOYED graph only (`job_id` all-zero). A per-run snapshot is keyed to a
|
||
/// job in the source workspace, which nothing in the fork can ask for, so
|
||
/// copying them adds another workspace's run history — `raw_code` and all — to
|
||
/// every fork transaction to be reclaimed later by the age sweep.
|
||
async fn clone_dbt_graph(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id,
|
||
resource_type, name, asset_path, materialized, materialize_strategy, unique_key,
|
||
tags, description, test_kind, test_column, test_args, severity, attached_node,
|
||
columns, column_schema, freshness, raw_code, original_file_path, ingested_at)
|
||
SELECT $2, script_path, script_hash, job_id, unique_id,
|
||
resource_type, name, asset_path, materialized, materialize_strategy, unique_key,
|
||
tags, description, test_kind, test_column, test_args, severity, attached_node,
|
||
columns, column_schema, freshness, raw_code, original_file_path, ingested_at
|
||
FROM dbt_node
|
||
WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'",
|
||
source_workspace_id,
|
||
target_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"INSERT INTO dbt_edge (workspace_id, script_path, script_hash, job_id,
|
||
parent_unique_id, child_unique_id, ingested_at)
|
||
SELECT $2, script_path, script_hash, job_id, parent_unique_id, child_unique_id,
|
||
ingested_at
|
||
FROM dbt_edge
|
||
WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'",
|
||
source_workspace_id,
|
||
target_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
// Column lineage travels with the rest of the graph, and it has to: the
|
||
// snapshot's digest covers it, so a fork missing these rows recomputes the
|
||
// digest the source stored, matches, and stores nothing — leaving the
|
||
// lineage gone until someone redeploys, which is the failure this whole
|
||
// function exists to prevent.
|
||
sqlx::query!(
|
||
"INSERT INTO dbt_column_edge (workspace_id, script_path, script_hash, job_id,
|
||
parent_unique_id, parent_column, child_unique_id, child_column, lineage_kind,
|
||
ingested_at)
|
||
SELECT $2, script_path, script_hash, job_id, parent_unique_id, parent_column,
|
||
child_unique_id, child_column, lineage_kind, ingested_at
|
||
FROM dbt_column_edge
|
||
WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'",
|
||
source_workspace_id,
|
||
target_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"INSERT INTO dbt_graph_snapshot (workspace_id, script_path, script_hash, job_id,
|
||
digest, relation_root_at_last_ingest, ingested_at)
|
||
SELECT $2, script_path, script_hash, job_id, digest, relation_root_at_last_ingest,
|
||
ingested_at
|
||
FROM dbt_graph_snapshot
|
||
WHERE workspace_id = $1 AND job_id = '00000000-0000-0000-0000-000000000000'",
|
||
source_workspace_id,
|
||
target_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_ci_test_references(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind)
|
||
SELECT $2, test_script_path, test_script_hash, tested_item_path, tested_item_kind
|
||
FROM ci_test_reference WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
// DuckDB macro registry + call-site edges are deploy-derived like
|
||
// ci_test_reference: without cloning them, a forked consumer script fails at
|
||
// run time until every macro library is manually redeployed in the fork.
|
||
async fn clone_macro_registry(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO macro_definition (workspace_id, name, provider_path, params, body, is_table_macro)
|
||
SELECT $2, name, provider_path, params, body, is_table_macro
|
||
FROM macro_definition WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"INSERT INTO macro_usage (workspace_id, consumer_path, macro_name)
|
||
SELECT $2, consumer_path, macro_name
|
||
FROM macro_usage WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
// Declared measures/dimensions are deploy-derived like the macro registry:
|
||
// without cloning them the fork's editor and agent tools report no metrics until
|
||
// every producer is manually redeployed there.
|
||
async fn clone_metric_catalog(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO data_metric (workspace_id, script_path, table_path, kind, name, expr, filter)
|
||
SELECT $2, script_path, table_path, kind, name, expr, filter
|
||
FROM data_metric WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
// Asset usage rows and `// on` subscriber triggers are deploy-derived like
|
||
// ci_test_reference / the macro registry: without cloning them the fork's
|
||
// pipeline graph has no asset nodes or lineage edges, and — worse — the asset
|
||
// dispatch cascade never fires in the fork (it reads `script_trigger`), so
|
||
// materializing an upstream node can't trigger its consumers until every
|
||
// script is manually redeployed. `job`-kind usage rows (runtime-detected,
|
||
// ephemeral) are skipped like the graph does.
|
||
async fn clone_asset_usages_and_triggers(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)
|
||
SELECT $2, path, kind, usage_access_type, usage_path, usage_kind, columns
|
||
FROM asset WHERE workspace_id = $1 AND usage_kind IN ('script', 'flow')",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"INSERT INTO script_trigger (workspace_id, runnable_kind, runnable_path, trigger_kind, trigger_ref, join_all, debounce_s, retry_count, retry_delay_s)
|
||
SELECT $2, runnable_kind, runnable_path, trigger_kind, trigger_ref, join_all, debounce_s, retry_count, retry_delay_s
|
||
FROM script_trigger WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The address is only meaningful next to the principal it was derived from: where the clone
|
||
/// dropped one that names nobody in the target, an old worker reading the address alone would
|
||
/// still run the row as the account left behind.
|
||
///
|
||
/// Only where *the clone* dropped it. A source row that already had no principal is one a server
|
||
/// predating this release wrote, address alone; that address is all there is to recover it from,
|
||
/// and the migration that drops the column re-derives from it.
|
||
async fn clear_orphaned_compat_address(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
table: &str,
|
||
key: &str,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// SAFETY: `table` and `key` are literals from the two call sites, never user input.
|
||
sqlx::query(&format!(
|
||
"UPDATE {table} t SET on_behalf_of_email = NULL
|
||
WHERE t.workspace_id = $1 AND t.on_behalf_of IS NULL AND t.on_behalf_of_email IS NOT NULL
|
||
AND EXISTS (SELECT 1 FROM {table} s
|
||
WHERE s.workspace_id = $2 AND s.{key} = t.{key}
|
||
AND s.on_behalf_of IS NOT NULL)"
|
||
))
|
||
.bind(target_workspace_id)
|
||
.bind(source_workspace_id)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Carries over the recorded principal under the rule spelled out on [`clone_scripts`].
|
||
async fn clone_flows(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// First, clone flows without versions
|
||
sqlx::query!(
|
||
"INSERT INTO flow (
|
||
workspace_id, path, summary, description, value, edited_by, edited_at,
|
||
archived, schema, extra_perms, dependency_job, tag,
|
||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||
concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs
|
||
)
|
||
SELECT $2, path, summary, description, value, edited_by, edited_at,
|
||
archived, schema, extra_perms, NULL, tag,
|
||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||
concurrency_key, ARRAY[]::bigint[],
|
||
-- Same predicate as clone_scripts.
|
||
CASE WHEN on_behalf_of LIKE 'u/%' THEN
|
||
(SELECT on_behalf_of WHERE EXISTS (
|
||
SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar
|
||
AND u.username = substring(on_behalf_of from 3)
|
||
UNION ALL
|
||
SELECT 1 FROM password p WHERE p.super_admin
|
||
AND (p.username = substring(on_behalf_of from 3)
|
||
OR p.email = substring(on_behalf_of from 3))))
|
||
WHEN on_behalf_of LIKE 'g/%' THEN
|
||
(SELECT on_behalf_of WHERE EXISTS (
|
||
SELECT 1 FROM group_ g WHERE g.workspace_id = $2::varchar
|
||
AND g.name = substring(on_behalf_of from 3)))
|
||
ELSE
|
||
(SELECT on_behalf_of WHERE EXISTS (
|
||
SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar
|
||
AND u.username = on_behalf_of
|
||
UNION ALL
|
||
SELECT 1 FROM password p WHERE p.email = on_behalf_of
|
||
AND p.super_admin))
|
||
END, on_behalf_of_email, lock_error_logs
|
||
FROM flow
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
clear_orphaned_compat_address(tx, "flow", "path", source_workspace_id, target_workspace_id)
|
||
.await?;
|
||
|
||
// Then clone flow versions
|
||
let flow_versions = sqlx::query!(
|
||
"SELECT id, workspace_id, path, value, schema, created_by, created_at
|
||
FROM flow_version
|
||
WHERE workspace_id = $1
|
||
ORDER BY path, created_at",
|
||
source_workspace_id
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
|
||
for version in flow_versions {
|
||
let new_version_id = sqlx::query_scalar!(
|
||
"INSERT INTO flow_version (workspace_id, path, value, schema, created_by, created_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6)
|
||
RETURNING id",
|
||
target_workspace_id,
|
||
version.path,
|
||
version.value,
|
||
version.schema,
|
||
version.created_by,
|
||
version.created_at,
|
||
)
|
||
.fetch_one(&mut **tx)
|
||
.await?;
|
||
|
||
// Update flow to include this version
|
||
sqlx::query!(
|
||
"UPDATE flow
|
||
SET versions = array_append(versions, $1)
|
||
WHERE workspace_id = $2 AND path = $3",
|
||
new_version_id,
|
||
target_workspace_id,
|
||
version.path,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_flow_nodes(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO flow_node (workspace_id, hash, path, lock, code, flow, hash_v2)
|
||
SELECT $2,
|
||
(SELECT COALESCE(MAX(hash), 0) FROM flow_node) + row_number() OVER () AS new_hash,
|
||
source_fn.path, source_fn.lock, source_fn.code, source_fn.flow, source_fn.hash_v2
|
||
FROM flow_node source_fn
|
||
WHERE source_fn.workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Re-point a cloned app policy at the fork's creator, the way `create_app` / `update_app`
|
||
/// do for a caller who may not preserve someone else's identity: `on_behalf_of` is what
|
||
/// anonymous and publisher executions queue jobs under, and the fork's endpoint outlives
|
||
/// any revocation in the parent.
|
||
fn repoint_cloned_app_identity(policy: &mut serde_json::Value, authed: &ApiAuthed) {
|
||
let Some(obj) = policy.as_object_mut() else {
|
||
return;
|
||
};
|
||
obj.insert(
|
||
"on_behalf_of".to_string(),
|
||
serde_json::Value::String(username_to_permissioned_as(&authed.username)),
|
||
);
|
||
obj.insert(
|
||
"on_behalf_of_email".to_string(),
|
||
serde_json::Value::String(authed.email.clone()),
|
||
);
|
||
}
|
||
|
||
async fn clone_apps(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
authed: &ApiAuthed,
|
||
) -> Result<HashMap<i64, i64>> {
|
||
// `execution_mode` is cloned as-is: protection rules are workspace-scoped and not cloned, so
|
||
// forcing `publisher` is only a speed bump (the creator can publish an anonymous app in the
|
||
// fork freely), and it is the one policy field a deploy back to the parent carries verbatim —
|
||
// `update_app` recomputes the identity but writes the policy wholesale.
|
||
let preserve_identity = windmill_common::can_preserve_on_behalf_of(authed);
|
||
// Get all apps from source workspace
|
||
let apps = sqlx::query!(
|
||
"SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path
|
||
FROM app
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
|
||
let mut app_id_mapping: HashMap<i64, i64> = HashMap::new();
|
||
// Only a raw app's current (last) version has a bundle worth carrying into the fork: bundles exist
|
||
// only for raw apps, and older versions aren't viewable/runnable (the bundle secret is only ever
|
||
// minted for `versions.last()`). Copying a bundle for every version of every app is what makes
|
||
// forking a workspace with many app versions hang — a serial S3 round-trip per version, held inside
|
||
// the fork transaction. Collect each app's current version here; intersect with raw versions below.
|
||
let mut latest_version_ids: HashSet<i64> = HashSet::new();
|
||
|
||
// Clone apps with new IDs
|
||
for mut app in apps {
|
||
if let Some(¤t_version) = app.versions.last() {
|
||
latest_version_ids.insert(current_version);
|
||
}
|
||
if !preserve_identity {
|
||
repoint_cloned_app_identity(&mut app.policy, authed);
|
||
}
|
||
// Both halves of what `create_app` demands to set a custom path: admin, and — unless
|
||
// paths are scoped per workspace — that nobody else holds it. Cloning one instance-wide
|
||
// would leave the parent's live public URL, resolved with no workspace filter and no
|
||
// ordering, answering from either row.
|
||
let scoped = *CLOUD_HOSTED
|
||
|| windmill_common::apps::APP_WORKSPACED_ROUTE
|
||
.load(std::sync::atomic::Ordering::Relaxed);
|
||
let custom_path = if scoped && authed.is_admin {
|
||
app.custom_path
|
||
} else {
|
||
None
|
||
};
|
||
let new_app_id = sqlx::query_scalar!(
|
||
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
RETURNING id",
|
||
target_workspace_id,
|
||
app.path,
|
||
app.summary,
|
||
app.policy,
|
||
&Vec::<i64>::new(), // Start with empty versions array
|
||
app.extra_perms,
|
||
custom_path,
|
||
)
|
||
.fetch_one(&mut **tx)
|
||
.await?;
|
||
|
||
app_id_mapping.insert(app.id, new_app_id);
|
||
}
|
||
|
||
let mut version_id_mapping: HashMap<i64, i64> = HashMap::new();
|
||
let mut raw_version_ids: HashSet<i64> = HashSet::new();
|
||
|
||
{
|
||
// Clone app versions
|
||
let app_versions = sqlx::query!(
|
||
"SELECT id, app_id, value, created_by, created_at, raw_app
|
||
FROM app_version
|
||
WHERE app_id = ANY(SELECT id FROM app WHERE workspace_id = $1)
|
||
ORDER BY app_id, created_at",
|
||
source_workspace_id
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
|
||
for version in app_versions {
|
||
if let Some(&new_app_id) = app_id_mapping.get(&version.app_id) {
|
||
if version.raw_app {
|
||
raw_version_ids.insert(version.id);
|
||
}
|
||
let new_version_id = sqlx::query_scalar!(
|
||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||
VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||
new_app_id,
|
||
version.value,
|
||
version.created_by,
|
||
version.created_at,
|
||
version.raw_app,
|
||
)
|
||
.fetch_one(&mut **tx)
|
||
.await?;
|
||
|
||
version_id_mapping.insert(version.id, new_version_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The bundles worth cloning: each raw app's current version (latest ∩ raw). Everything else either
|
||
// has no bundle (low-code apps) or an unreachable one (older versions), so we don't touch S3 for it.
|
||
let bundle_version_ids: HashSet<i64> = latest_version_ids
|
||
.intersection(&raw_version_ids)
|
||
.copied()
|
||
.collect();
|
||
|
||
// Clone app bundles — only the current version of each raw app (see bundle_version_ids).
|
||
if !bundle_version_ids.is_empty() {
|
||
let old_ids: Vec<i64> = bundle_version_ids.iter().copied().collect();
|
||
let bundles = sqlx::query!(
|
||
"SELECT app_version_id, file_type, data FROM app_bundles
|
||
WHERE app_version_id = ANY($1) AND w_id = $2",
|
||
&old_ids,
|
||
source_workspace_id
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
|
||
let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new();
|
||
for bundle in &bundles {
|
||
cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone()));
|
||
}
|
||
|
||
for bundle in bundles {
|
||
if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) {
|
||
sqlx::query!(
|
||
"INSERT INTO app_bundles (app_version_id, w_id, file_type, data)
|
||
VALUES ($1, $2, $3, $4)",
|
||
new_version_id,
|
||
target_workspace_id,
|
||
bundle.file_type,
|
||
bundle.data,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
// Clone bundles from S3 for versions not found in DB
|
||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||
{
|
||
let object_store = windmill_object_store::get_object_store().await;
|
||
if let Some(os) = object_store {
|
||
for (&old_version_id, &new_version_id) in &version_id_mapping {
|
||
if !bundle_version_ids.contains(&old_version_id) {
|
||
continue;
|
||
}
|
||
for file_type in &["js", "css"] {
|
||
if cloned_from_db.contains(&(old_version_id, file_type.to_string())) {
|
||
continue;
|
||
}
|
||
// Prefer a server-side copy (no bytes through the backend). A missing source —
|
||
// e.g. a raw app with a js bundle but no css — surfaces as NotFound and is
|
||
// skipped. Not every object-store provider supports server-side copy, so fall
|
||
// back to download+upload on any other error.
|
||
let src_path =
|
||
windmill_object_store::object_store_reexports::Path::from(format!(
|
||
"/app_bundles/{}/{}.{}",
|
||
source_workspace_id, old_version_id, file_type
|
||
));
|
||
let dst_path =
|
||
windmill_object_store::object_store_reexports::Path::from(format!(
|
||
"/app_bundles/{}/{}.{}",
|
||
target_workspace_id, new_version_id, file_type
|
||
));
|
||
match os.copy(&src_path, &dst_path).await {
|
||
Ok(()) => {
|
||
tracing::info!(
|
||
"Cloned app bundle in object store: {}.{} -> {}.{}",
|
||
old_version_id,
|
||
file_type,
|
||
new_version_id,
|
||
file_type
|
||
);
|
||
}
|
||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => {
|
||
// No bundle in the object store for this version/type, skip
|
||
}
|
||
Err(copy_err) => {
|
||
// Provider may not support server-side copy — fall back to get+put.
|
||
tracing::warn!(
|
||
"object store copy failed ({copy_err:#}), falling back to get+put for app bundle {}.{}",
|
||
old_version_id, file_type
|
||
);
|
||
match os.get(&src_path).await {
|
||
Ok(result) => {
|
||
let data = result.bytes().await.map_err(
|
||
windmill_object_store::object_store_error_to_error,
|
||
)?;
|
||
os.put(&dst_path, data.into()).await.map_err(
|
||
windmill_object_store::object_store_error_to_error,
|
||
)?;
|
||
tracing::info!(
|
||
"Cloned app bundle via get+put fallback: {}.{} -> {}.{}",
|
||
old_version_id,
|
||
file_type,
|
||
new_version_id,
|
||
file_type
|
||
);
|
||
}
|
||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => {
|
||
// No bundle for this version/type, skip
|
||
}
|
||
Err(e) => {
|
||
return Err(
|
||
windmill_object_store::object_store_error_to_error(e),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update app versions arrays
|
||
sqlx::query!(
|
||
"UPDATE app SET versions = (
|
||
SELECT array_agg(av.id ORDER BY av.created_at)
|
||
FROM app_version av
|
||
WHERE av.app_id = app.id
|
||
) WHERE workspace_id = $1",
|
||
target_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
// Clone app scripts with recomputed hashes
|
||
let app_scripts = sqlx::query!(
|
||
"SELECT app, hash, lock, code, code_sha256
|
||
FROM app_script
|
||
WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)",
|
||
source_workspace_id
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
|
||
for app_script in app_scripts {
|
||
if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) {
|
||
// Recompute hash using app_id, code_sha256, and lock
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(new_app_id.to_be_bytes());
|
||
hasher.update(hex::decode(&app_script.code_sha256)?);
|
||
if let Some(lock) = &app_script.lock {
|
||
hasher.update(lock.as_bytes());
|
||
}
|
||
let new_hash = hex::encode(hasher.finalize());
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO app_script (app, hash, lock, code, code_sha256)
|
||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
||
new_app_id,
|
||
new_hash,
|
||
app_script.lock,
|
||
app_script.code,
|
||
app_script.code_sha256,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
Ok(app_id_mapping)
|
||
}
|
||
|
||
async fn clone_raw_apps(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
"INSERT INTO raw_app (path, version, workspace_id, summary, edited_at, data, extra_perms)
|
||
SELECT path, version, $2, summary, edited_at, data, extra_perms
|
||
FROM raw_app
|
||
WHERE workspace_id = $1",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Clone every per-user draft (and the legacy NULL-email workspace draft,
|
||
/// if present) from the parent. The fork target is empty at create time so
|
||
/// a plain INSERT is safe — no need to UPSERT against the partial unique
|
||
/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the
|
||
/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list
|
||
/// it in the column set. `created_at` is preserved so the per-tab
|
||
/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's
|
||
/// `draft_saved_at`) lines up with the parent's timeline — otherwise the
|
||
/// fork's first POST from any open editor would race a stale `last_sync`
|
||
/// and trip the conflict modal on every cloned draft.
|
||
// Only `email = authed_email` and the legacy NULL row are cloned — see
|
||
// `clone_workspace_data` for the rationale.
|
||
async fn clone_drafts(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
authed_email: &str,
|
||
) -> Result<()> {
|
||
// A script/flow draft carries the principal in its value, and deploying it in the clone
|
||
// would send a pair naming somebody who is not a member there. Stripped rather than
|
||
// filtered like `clone_scripts`: the address the draft still carries re-derives the
|
||
// clone's own principal at deploy time, which is the more accurate answer of the two.
|
||
sqlx::query!(
|
||
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
|
||
SELECT $2, path, typ,
|
||
CASE WHEN typ IN ('script', 'flow')
|
||
THEN to_json(to_jsonb(value) - 'on_behalf_of')
|
||
ELSE value END,
|
||
created_at, email
|
||
FROM draft
|
||
WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||
source_workspace_id,
|
||
target_workspace_id,
|
||
authed_email,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_workspace_runnable_dependencies(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// Clone workspace_runnable_dependencies
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)
|
||
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path
|
||
FROM workspace_runnable_dependencies
|
||
WHERE workspace_id = $2",
|
||
target_workspace_id,
|
||
source_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
// Recorded so the clone's own relocks have something to match; with no row they record NULL
|
||
// and nothing in it ever skips. Hashed from the locks the clone holds rather than copied from
|
||
// the source's rows, which are only as current as the last write to them: one left stale by a
|
||
// supplied lock deployed before this was recorded names a lock the clone no longer has, and an
|
||
// importer that resolved against the real one would then skip a relock it needed.
|
||
record_lock_hashes_for_workspace(tx, target_workspace_id).await?;
|
||
|
||
// Deliberately without `imported_lockfile_hash`: it records what an importer resolved against
|
||
// when it was last locked, which nothing here can establish for the version the clone got.
|
||
// Left NULL, every importer relocks once and re-anchors both sides to what the clone holds.
|
||
sqlx::query!(
|
||
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id)
|
||
SELECT $1, importer_path, importer_kind, imported_path, importer_node_id
|
||
FROM dependency_map
|
||
WHERE workspace_id = $2",
|
||
target_workspace_id,
|
||
source_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn clone_workspace_dependencies(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
source_workspace_id: &str,
|
||
target_workspace_id: &str,
|
||
) -> Result<()> {
|
||
// Clone workspace_dependencies
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_dependencies (workspace_id, language, name, description, content, archived, created_at)
|
||
SELECT $1, language, name, description, content, archived, created_at
|
||
FROM workspace_dependencies
|
||
WHERE workspace_id = $2",
|
||
target_workspace_id,
|
||
source_workspace_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result<String> {
|
||
return Err(Error::BadRequest("This API endpoint has been relocated. Your Windmill CLI version is outdated and needs to be updated.".to_string()));
|
||
}
|
||
|
||
/// Return the uuids of the git sync jobs to create the branch before creating the fork
|
||
async fn create_workspace_fork_branch(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(nw): Json<CreateWorkspaceFork>,
|
||
) -> JsonResult<Vec<Uuid>> {
|
||
// Pre-check the fork guards before creating any git branch, so we don't leave orphaned branches
|
||
// behind when the follow-up create_workspace_fork would be rejected anyway.
|
||
enforce_fork_depth(&db, &w_id, 0).await?;
|
||
#[cfg(feature = "cloud")]
|
||
if *CLOUD_HOSTED {
|
||
enforce_cloud_fork_cap(&db, &w_id).await?;
|
||
}
|
||
|
||
if *DISABLE_WORKSPACE_FORK {
|
||
require_super_admin(&db, &authed).await?;
|
||
}
|
||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||
&w_id,
|
||
&ProtectionRuleKind::DisableWorkspaceForking,
|
||
AuditAuthorable::username(&authed),
|
||
&authed.groups,
|
||
authed.is_admin,
|
||
&db,
|
||
)
|
||
.await?
|
||
{
|
||
return Err(Error::PermissionDenied(msg));
|
||
}
|
||
|
||
// Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and
|
||
// validates up front; it does NOT create the workspace row. The caller follows up with
|
||
// `create_workspace_fork`, which inserts the row and applies the dev designation + prod lock +
|
||
// member copy. So the dev/lock/copy_members fields here are validated only — they are acted on by
|
||
// that second call. Validating early lets a bad request fail before any branch is created.
|
||
if nw.is_dev_workspace {
|
||
validate_dev_workspace_id(&nw.id)?;
|
||
// Reject a bad label before any git branch is created (acted on in create_workspace_fork).
|
||
let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
|
||
reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&w_id]).await?;
|
||
ensure_dev_parent_can_host_dev(&db, &w_id).await?;
|
||
reject_dev_label_taken_in_chain(
|
||
&mut *db.acquire().await?,
|
||
&w_id,
|
||
&nw.id,
|
||
false,
|
||
label.as_deref(),
|
||
)
|
||
.await?;
|
||
// Reject before creating any git branch if the parent already has a dev workspace,
|
||
// otherwise the deferred branch-creation job leaves a dangling branch on the synced repos.
|
||
ensure_no_existing_dev_workspace(&db, &w_id).await?;
|
||
// Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking the
|
||
// parent mutates its protection rules), so require admin of the parent regardless of the lock
|
||
// flags — mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker
|
||
// could claim the dev slot. Enforced in this first phase too so the request fails before any
|
||
// git branch is created rather than leaving dangling branches.
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
} else {
|
||
validate_fork_workspace_id(&nw.id)?;
|
||
}
|
||
validate_workspace_name(&nw.name)?;
|
||
|
||
// Fail before creating any git branch so a name conflict doesn't leave a
|
||
// dangling branch on the synced repos.
|
||
check_fork_w_id_conflict(&db, &nw.id).await?;
|
||
purge_stale_fork_diff_state(&db, &nw.id).await?;
|
||
|
||
Ok(Json(
|
||
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
|
||
))
|
||
}
|
||
|
||
/// Update a forked workspace's datatable config to point to the new database.
|
||
/// For instance datatables: updates resource_path in the datatable config.
|
||
/// For resource datatables: updates the resource's dbname and marks it as ws_specific.
|
||
/// Snapshot the schema from the source datatable by connecting to its database.
|
||
async fn snapshot_datatable_schema(
|
||
db: &DB,
|
||
parent_w_id: &str,
|
||
dt_name: &str,
|
||
) -> Result<serde_json::Value> {
|
||
let pg = get_datatable_resource_from_db_unchecked(db, parent_w_id, dt_name).await?;
|
||
let pg: PgDatabase = serde_json::from_value(pg)
|
||
.map_err(|e| Error::internal_err(format!("Failed to parse db credentials: {}", e)))?;
|
||
let (client, connection) = pg.connect(Some(db)).await?;
|
||
let join_handle = tokio::spawn(async move { connection.await });
|
||
|
||
let schema = windmill_common::query_builders::pg_get_full_schema(&client)
|
||
.await
|
||
.map_err(Error::internal_err)?;
|
||
|
||
drop(client);
|
||
windmill_common::shutdown_pg_connection(join_handle).await?;
|
||
|
||
serde_json::to_value(schema)
|
||
.map_err(|e| Error::internal_err(format!("Failed to serialize schema: {}", e)))
|
||
}
|
||
|
||
/// Turn every data table the fork chose to keep into a pointer at the parent's entry.
|
||
///
|
||
/// `clone_workspace_data` copies `workspace_settings` wholesale, so a kept data table arrives as a
|
||
/// byte-identical copy naming the parent's database — including the parent's `permissions`, which
|
||
/// a fork admin could then edit to widen their own access to it. A pointer has nothing local to
|
||
/// edit: the parent's entry stays the only place the decision lives.
|
||
///
|
||
/// The cloned data tables are skipped: they own a fresh database of their own, and they keep the
|
||
/// copied `permissions` as their starting point, which they then govern.
|
||
async fn point_kept_datatables_at_parent(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
parent_w_id: &str,
|
||
forked_w_id: &str,
|
||
cloned: &[ForkedDatatableInfo],
|
||
) -> Result<()> {
|
||
let settings: Option<serde_json::Value> = sqlx::query_scalar!(
|
||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
|
||
forked_w_id
|
||
)
|
||
.fetch_optional(&mut **tx)
|
||
.await?
|
||
.flatten();
|
||
|
||
let Some(mut settings) = settings else {
|
||
return Ok(());
|
||
};
|
||
let Some(datatables) = settings
|
||
.get_mut("datatables")
|
||
.and_then(|d| d.as_object_mut())
|
||
else {
|
||
return Ok(());
|
||
};
|
||
|
||
let mut changed = false;
|
||
for (name, entry) in datatables.iter_mut() {
|
||
if cloned.iter().any(|c| &c.name == name) {
|
||
continue;
|
||
}
|
||
let dt: DataTable = match serde_json::from_value(entry.clone()) {
|
||
Ok(dt) => dt,
|
||
Err(_) => continue,
|
||
};
|
||
// Already a pointer: the parent was itself a fork, and its entry names the workspace that
|
||
// governs. Following it from here is the same answer, so leave it alone.
|
||
if dt.reference.is_some() {
|
||
continue;
|
||
}
|
||
// Only instance databases. A resource-backed data table names a resource, and the settings
|
||
// clone gave the fork its own copy of that resource in its own workspace — pointing at the
|
||
// parent's entry would silently move the fork onto the parent's resource instead.
|
||
if dt
|
||
.database
|
||
.as_ref()
|
||
.is_none_or(|d| d.resource_type != DataTableCatalogResourceType::Instance)
|
||
{
|
||
continue;
|
||
}
|
||
*entry = serde_json::to_value(DataTable {
|
||
database: None,
|
||
reference: Some(windmill_common::workspaces::DataTableReference {
|
||
workspace_id: parent_w_id.to_string(),
|
||
datatable: name.clone(),
|
||
}),
|
||
forked_from: None,
|
||
migrations_enabled: dt.migrations_enabled,
|
||
permissions: None,
|
||
})
|
||
.map_err(|e| Error::internal_err(format!("serializing data table '{name}': {e}")))?;
|
||
changed = true;
|
||
}
|
||
|
||
if changed {
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||
settings,
|
||
forked_w_id
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Move every pointer in any workspace that names `(w_id, from)` to `(w_id, to)`.
|
||
///
|
||
/// `EXISTS` rather than a `LIKE` over the whole document: the update rewrites the row, so matching
|
||
/// every workspace that holds any pointer would rewrite rows to a byte-identical value and hold an
|
||
/// exclusive lock on them until commit.
|
||
async fn repoint_datatable_references(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
w_id: &str,
|
||
from: &str,
|
||
to: &str,
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
r#"UPDATE workspace_settings ws
|
||
SET datatable = (
|
||
SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(
|
||
dt.key,
|
||
CASE WHEN dt.value->'reference'->>'workspace_id' = $1
|
||
AND dt.value->'reference'->>'datatable' = $2
|
||
THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))
|
||
ELSE dt.value END
|
||
))
|
||
FROM jsonb_each(ws.datatable->'datatables') dt
|
||
)
|
||
WHERE EXISTS (
|
||
SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d
|
||
WHERE d.value->'reference'->>'workspace_id' = $1
|
||
AND d.value->'reference'->>'datatable' = $2
|
||
)"#,
|
||
w_id,
|
||
from,
|
||
to,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn apply_forked_datatable(
|
||
db: &DB,
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
authed: &ApiAuthed,
|
||
parent_w_id: &str,
|
||
forked_w_id: &str,
|
||
fdt: &ForkedDatatableInfo,
|
||
) -> Result<()> {
|
||
// Cloning reads the parent's whole schema as admin and hands the copy to the fork, so it is
|
||
// for the workspace that governs the data table — a fork can use one, never duplicate it.
|
||
windmill_common::workspaces::ensure_datatable_admin_access(
|
||
db,
|
||
parent_w_id,
|
||
&fdt.name,
|
||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||
)
|
||
.await?;
|
||
let governing = ensure_datatable_is_clonable(db, parent_w_id, &fdt.name).await?;
|
||
windmill_common::validate_dbname(&fdt.new_dbname)?;
|
||
if !fdt.new_dbname.starts_with("wm_fork_") {
|
||
return Err(Error::BadRequest(format!(
|
||
"Forked datatable database name '{}' must start with 'wm_fork_'",
|
||
fdt.new_dbname
|
||
)));
|
||
}
|
||
|
||
// Snapshot the schema from the source (parent) datatable
|
||
let schema = snapshot_datatable_schema(db, parent_w_id, &fdt.name).await?;
|
||
let forked_from = serde_json::json!({ "schema": schema });
|
||
|
||
// Read the datatable config from the forked workspace
|
||
let config_val = sqlx::query_scalar!(
|
||
"SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1",
|
||
forked_w_id,
|
||
&fdt.name
|
||
)
|
||
.fetch_optional(&mut **tx)
|
||
.await?
|
||
.flatten()
|
||
.ok_or_else(|| {
|
||
Error::NotFound(format!(
|
||
"Datatable '{}' not found in workspace '{}'",
|
||
fdt.name, forked_w_id
|
||
))
|
||
})?;
|
||
|
||
let dt: DataTable = serde_json::from_value(config_val)
|
||
.map_err(|e| Error::internal_err(format!("Failed to parse datatable config: {}", e)))?;
|
||
|
||
// A clone owns its copy, so the fork's entry has to be terminal. When the parent was itself a
|
||
// fork the settings clone hands down a pointer instead, and what it points at is what the copy
|
||
// was taken from. `ensure_datatable_is_clonable` already settled that this shape can be
|
||
// cloned, so there is nothing left to refuse here — by now the database exists and is filled.
|
||
let database = match dt.database.clone() {
|
||
Some(database) => database,
|
||
None => governing.datatable.database.clone().ok_or_else(|| {
|
||
Error::internal_err(format!(
|
||
"Data table '{}' resolves to an entry that owns no database",
|
||
fdt.name
|
||
))
|
||
})?,
|
||
};
|
||
|
||
if database.resource_type == DataTableCatalogResourceType::Instance {
|
||
// The whole `database` object, not just its `resource_path`: a pointer entry has none to
|
||
// patch. `reference` goes with it — exactly one of the two may be set.
|
||
let new_database = serde_json::json!({
|
||
"resource_type": "instance",
|
||
"resource_path": &fdt.new_dbname,
|
||
});
|
||
sqlx::query!(
|
||
r#"UPDATE workspace_settings
|
||
SET datatable = jsonb_set(
|
||
jsonb_set(
|
||
datatable #- ARRAY['datatables', $2, 'reference'],
|
||
ARRAY['datatables', $2, 'database'], $3::jsonb),
|
||
ARRAY['datatables', $2, 'forked_from'], $4::jsonb
|
||
)
|
||
WHERE workspace_id = $1"#,
|
||
forked_w_id,
|
||
&fdt.name,
|
||
new_database,
|
||
forked_from,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
} else {
|
||
// Resource: update the resource's dbname and mark as ws_specific
|
||
let resource_path = &database.resource_path;
|
||
sqlx::query!(
|
||
r#"UPDATE resource
|
||
SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text))
|
||
WHERE workspace_id = $1 AND path = $2"#,
|
||
forked_w_id,
|
||
resource_path,
|
||
&fdt.new_dbname,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO ws_specific (workspace_id, item_kind, path) VALUES ($1, 'resource', $2) ON CONFLICT DO NOTHING",
|
||
forked_w_id,
|
||
resource_path,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
// Set forked_from on the datatable config
|
||
sqlx::query!(
|
||
r#"UPDATE workspace_settings
|
||
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'forked_from'], $3::jsonb)
|
||
WHERE workspace_id = $1"#,
|
||
forked_w_id,
|
||
&fdt.name,
|
||
forked_from,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Cloud: require the fork/dev's root (billing) workspace be premium; returns the resolved root id.
|
||
#[cfg(feature = "cloud")]
|
||
async fn require_cloud_fork_premium(db: &DB, parent_workspace_id: &str) -> Result<String> {
|
||
let root =
|
||
windmill_common::workspaces::get_billing_workspace_id(db, parent_workspace_id).await?;
|
||
if !windmill_common::workspaces::get_team_plan_status(db, &root)
|
||
.await?
|
||
.premium
|
||
{
|
||
return Err(Error::BadRequest(
|
||
"Creating a fork or dev workspace on the cloud requires a paid team plan. Upgrade the workspace first.".to_string(),
|
||
));
|
||
}
|
||
Ok(root)
|
||
}
|
||
|
||
/// Cloud: reject if adding `incoming` fork/dev workspaces would push `root`'s family over its per-seat
|
||
/// allotment. `incoming` is the number of workspaces the operation adds to the family — 1 for a plain
|
||
/// create, but `1 + candidate_subtree` for an attach whose candidate already has child forks.
|
||
#[cfg(feature = "cloud")]
|
||
async fn enforce_cloud_fork_count(db: &DB, root: &str, incoming: i64) -> Result<()> {
|
||
let seats = windmill_common::workspaces::count_paid_seats(db, root).await?;
|
||
let per_seat = *MAX_FORKS_PER_SEAT;
|
||
// Any premium workspace has at least one paid seat, so floor the seat count at 1.
|
||
let allowed = seats.max(1) * per_seat;
|
||
|
||
let existing = windmill_common::workspaces::count_workspace_forks(db, root).await?;
|
||
let projected = existing + incoming;
|
||
if projected > allowed {
|
||
return Err(Error::BadRequest(format!(
|
||
"Fork limit reached: this would bring the workspace family to {projected} fork(s), over the cap of {allowed} ({seats} paid seat(s) × {per_seat} per seat). Delete a fork or add seats."
|
||
)));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Cloud-only guard for creating a fork/dev workspace. Forks piggyback on the parent's plan (a fork
|
||
/// inherits the root's premium and meters its usage into the root's bill), so forking is limited to
|
||
/// premium workspaces and capped at `MAX_FORKS_PER_SEAT` per paid (developer) seat of the root.
|
||
#[cfg(feature = "cloud")]
|
||
async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()> {
|
||
let root = require_cloud_fork_premium(db, parent_workspace_id).await?;
|
||
enforce_cloud_fork_count(db, &root, 1).await
|
||
}
|
||
|
||
/// Cloud: refuse to attach a workspace that already has a paid plan of its own.
|
||
///
|
||
/// Once attached it draws the root's plan and meters its usage there, so a subscription of its own
|
||
/// bills a second time for one plan. Only an attach can reach this state: a fork is created as a
|
||
/// fresh workspace and never had a plan to keep.
|
||
///
|
||
/// Asked only of a candidate joining this family, never of one already under the same root: that
|
||
/// one is already in the double-billed state, where the settings page surfaces the leftover
|
||
/// subscription and the portal that cancels it, and refusing there would block re-designating a
|
||
/// renamed dev workspace over a billing problem the attach did not cause.
|
||
#[cfg(feature = "cloud")]
|
||
async fn reject_attach_of_subscribed_workspace(db: &DB, dev_w_id: &str) -> Result<()> {
|
||
let plan = sqlx::query_scalar!(
|
||
"SELECT plan FROM workspace_settings WHERE workspace_id = $1",
|
||
dev_w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?
|
||
.flatten();
|
||
// Any plan, not just `'team'`: the column is written by the subscription webhook, and a plan
|
||
// value it does not write yet would otherwise walk straight past this. An enterprise
|
||
// arrangement is deliberately not covered — it sets `premium` without a plan and has no
|
||
// self-serve portal, so refusing there would be a dead end rather than something to act on.
|
||
if plan.is_some() {
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace {dev_w_id} is on a paid plan of its own. A dev or fork workspace runs on its parent's plan and is never invoiced separately, so cancel that subscription from its own billing settings before attaching it."
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(all(test, feature = "cloud"))]
|
||
mod attach_billing_guard_tests {
|
||
use super::reject_attach_of_subscribed_workspace;
|
||
use sqlx::{Pool, Postgres};
|
||
|
||
async fn workspace_on_plan(db: &Pool<Postgres>, id: &str, plan: Option<&str>) {
|
||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')")
|
||
.bind(id)
|
||
.execute(db)
|
||
.await
|
||
.expect("insert workspace");
|
||
sqlx::query("INSERT INTO workspace_settings (workspace_id, plan) VALUES ($1, $2)")
|
||
.bind(id)
|
||
.bind(plan)
|
||
.execute(db)
|
||
.await
|
||
.expect("insert workspace_settings");
|
||
}
|
||
|
||
#[sqlx::test(migrations = "../migrations")]
|
||
async fn refuses_a_candidate_that_still_pays_for_itself(db: Pool<Postgres>) {
|
||
workspace_on_plan(&db, "subscribed", Some("team")).await;
|
||
workspace_on_plan(&db, "cancelled", None).await;
|
||
|
||
let err = reject_attach_of_subscribed_workspace(&db, "subscribed")
|
||
.await
|
||
.expect_err("a workspace on a paid plan of its own must not be attachable");
|
||
assert!(err.to_string().contains("paid plan of its own"), "{err}");
|
||
|
||
// Cancelling clears `plan` but keeps `customer_id`, so the plan column is what decides.
|
||
reject_attach_of_subscribed_workspace(&db, "cancelled")
|
||
.await
|
||
.expect("a workspace with no plan is attachable");
|
||
reject_attach_of_subscribed_workspace(&db, "no-settings-row")
|
||
.await
|
||
.expect("a workspace with no settings row is attachable");
|
||
}
|
||
}
|
||
|
||
/// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper
|
||
/// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new
|
||
/// node — 0 for a plain fork, or the candidate's own subtree height for an attach.
|
||
async fn enforce_fork_depth(
|
||
db: &DB,
|
||
parent_workspace_id: &str,
|
||
added_subtree_height: i64,
|
||
) -> Result<()> {
|
||
let parent_depth =
|
||
windmill_common::workspaces::fork_chain_depth(db, parent_workspace_id).await?;
|
||
// The new node sits one level below the parent; its deepest descendant adds the grafted height.
|
||
let resulting_depth = parent_depth + 1 + added_subtree_height;
|
||
if resulting_depth > *MAX_FORK_DEPTH {
|
||
return Err(Error::BadRequest(format!(
|
||
"Fork depth limit reached: forks can be nested at most {} level(s) deep, but this would create a fork at depth {}. Fork from a workspace closer to the root instead.",
|
||
*MAX_FORK_DEPTH, resulting_depth
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// True if `raw` (the text form of a `json` value) contains a genuine `\u0000`
|
||
/// NUL escape: a `u0000` preceded by an ODD run of backslashes. Mirrors the
|
||
/// parity rule in `windmill_common::utils::strip_json_nul` — an even run
|
||
/// (`\\u0000`) is an escaped backslash then the literal text "u0000" (common in
|
||
/// minified JS) and is jsonb-safe. A genuine NUL is exactly what the
|
||
/// `json`→`jsonb` re-encode in `clone_apps` / `clone_flows` rejects with
|
||
/// SQLSTATE 22P05.
|
||
fn json_text_has_nul_escape(raw: &str) -> bool {
|
||
let bytes = raw.as_bytes();
|
||
let mut search_from = 0;
|
||
while let Some(rel) = raw[search_from..].find("u0000") {
|
||
let at = search_from + rel;
|
||
let mut backslashes = 0;
|
||
let mut j = at;
|
||
while j > 0 && bytes[j - 1] == b'\\' {
|
||
backslashes += 1;
|
||
j -= 1;
|
||
}
|
||
if backslashes % 2 == 1 {
|
||
return true;
|
||
}
|
||
search_from = at + 5;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// SQLSTATE 22P05 (`untranslatable_character`) is what Postgres raises for
|
||
/// "unsupported Unicode escape sequence" when a `json` value carrying a genuine
|
||
/// `\u0000` is re-encoded to `jsonb` — the exact failure the per-row `json`
|
||
/// clones (`clone_apps`, `clone_flows`) hit when a source item holds a NUL.
|
||
fn is_unsupported_unicode_escape(e: &Error) -> bool {
|
||
matches!(
|
||
e,
|
||
Error::SqlErr { error, .. }
|
||
if error.as_database_error().and_then(|d| d.code()).as_deref() == Some("22P05")
|
||
)
|
||
}
|
||
|
||
/// After a fork clone aborts on a NUL escape, locate the offending source items
|
||
/// so the error can name them. Reads the committed source workspace on the pool
|
||
/// (the clone transaction is already poisoned and unusable). Only the `json`
|
||
/// columns re-encoded to `jsonb` during the clone can trigger the failure:
|
||
/// `app_version.value` (clone_apps) and `flow_version.schema` (clone_flows).
|
||
/// Best-effort — returns an empty list rather than erroring if a probe query
|
||
/// fails, so the caller can still surface a generic message.
|
||
async fn find_nul_escape_locations(db: &DB, workspace_id: &str) -> Vec<String> {
|
||
let mut apps: std::collections::BTreeSet<String> = Default::default();
|
||
if let Ok(rows) = sqlx::query(
|
||
"SELECT a.path AS path, av.value::text AS value
|
||
FROM app_version av JOIN app a ON a.id = av.app_id
|
||
WHERE a.workspace_id = $1 AND av.value IS NOT NULL",
|
||
)
|
||
.bind(workspace_id)
|
||
.fetch_all(db)
|
||
.await
|
||
{
|
||
for row in rows {
|
||
let value: String = row.get("value");
|
||
if json_text_has_nul_escape(&value) {
|
||
apps.insert(row.get::<String, _>("path"));
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut flows: std::collections::BTreeSet<String> = Default::default();
|
||
if let Ok(rows) = sqlx::query(
|
||
"SELECT fv.path AS path, fv.schema::text AS schema
|
||
FROM flow_version fv
|
||
WHERE fv.workspace_id = $1 AND fv.schema IS NOT NULL",
|
||
)
|
||
.bind(workspace_id)
|
||
.fetch_all(db)
|
||
.await
|
||
{
|
||
for row in rows {
|
||
let schema: String = row.get("schema");
|
||
if json_text_has_nul_escape(&schema) {
|
||
flows.insert(row.get::<String, _>("path"));
|
||
}
|
||
}
|
||
}
|
||
|
||
apps.into_iter()
|
||
.map(|p| format!("app: {p}"))
|
||
.chain(flows.into_iter().map(|p| format!("flow: {p}")))
|
||
.collect()
|
||
}
|
||
|
||
async fn create_workspace_fork(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(parent_workspace_id): Path<String>,
|
||
Json(nw): Json<CreateWorkspaceFork>,
|
||
) -> Result<String> {
|
||
enforce_fork_depth(&db, &parent_workspace_id, 0).await?;
|
||
#[cfg(feature = "cloud")]
|
||
if *CLOUD_HOSTED {
|
||
enforce_cloud_fork_cap(&db, &parent_workspace_id).await?;
|
||
}
|
||
|
||
if nw.is_dev_workspace {
|
||
validate_dev_workspace_id(&nw.id)?;
|
||
} else {
|
||
validate_fork_workspace_id(&nw.id)?;
|
||
}
|
||
validate_workspace_name(&nw.name)?;
|
||
// The environment label only applies to dev workspaces; a non-dev fork stores NULL.
|
||
let dev_workspace_label = if nw.is_dev_workspace {
|
||
let label = normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
|
||
reject_dev_label_matching_tracked_branch(&db, label.as_deref(), &[&parent_workspace_id])
|
||
.await?;
|
||
reject_dev_label_taken_in_chain(
|
||
&mut *db.acquire().await?,
|
||
&parent_workspace_id,
|
||
&nw.id,
|
||
false,
|
||
label.as_deref(),
|
||
)
|
||
.await?;
|
||
label
|
||
} else {
|
||
None
|
||
};
|
||
// Check the id conflict before the CE workspace-count limit so that
|
||
// re-using a taken (possibly archived) fork id reports the actual
|
||
// conflict instead of a misleading "maximum number of workspaces" error.
|
||
check_fork_w_id_conflict(&db, &nw.id).await?;
|
||
purge_stale_fork_diff_state(&db, &nw.id).await?;
|
||
// A previously deleted fork with this id may have left ducklake namespaces behind if its
|
||
// physical cleanup failed after the delete committed (registry rows are the durable
|
||
// ledger — no FK, they outlive the workspace). Retry that cleanup now, and refuse to
|
||
// proceed while any metadata schema still can't be dropped: creating the fork anyway
|
||
// would silently reattach the deterministic namespace and its stale tables. Data-file
|
||
// leftovers alone don't block — once the schema is gone they are inert (a deleted fork's
|
||
// `$res:` storage resource is gone forever, so they may never resolve again), and the
|
||
// surviving registry row has the next successful same-prefix cleanup sweep them.
|
||
// `$res:` fallback = the workspace being forked: the deleted fork's resource clones came
|
||
// from a parent, so the new parent is the natural donor for the retry's credentials.
|
||
let leftover_issues = crate::workspaces_extra::drop_forked_ducklake_namespaces_impl(
|
||
&db,
|
||
&nw.id,
|
||
Some(&parent_workspace_id),
|
||
)
|
||
.await?;
|
||
let blocking: Vec<&str> = leftover_issues
|
||
.iter()
|
||
.filter(|i| i.blocking)
|
||
.map(|i| i.msg.as_str())
|
||
.collect();
|
||
if !blocking.is_empty() {
|
||
return Err(Error::BadRequest(format!(
|
||
"a previously deleted workspace with id '{}' left ducklake namespaces that could \
|
||
not be cleaned up: {}; retry once the catalog is reachable again",
|
||
nw.id,
|
||
blocking.join("; ")
|
||
)));
|
||
}
|
||
for i in &leftover_issues {
|
||
tracing::warn!(
|
||
"creating fork {}: leftover ducklake cleanup: {}",
|
||
nw.id,
|
||
i.msg
|
||
);
|
||
}
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
_check_nb_of_workspaces(&db).await?;
|
||
|
||
if *DISABLE_WORKSPACE_FORK {
|
||
require_super_admin(&db, &authed).await?;
|
||
}
|
||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||
&parent_workspace_id,
|
||
&ProtectionRuleKind::DisableWorkspaceForking,
|
||
AuditAuthorable::username(&authed),
|
||
&authed.groups,
|
||
authed.is_admin,
|
||
&db,
|
||
)
|
||
.await?
|
||
{
|
||
return Err(Error::PermissionDenied(msg));
|
||
}
|
||
|
||
if nw.is_dev_workspace {
|
||
ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?;
|
||
// Creating the canonical dev consumes the parent's one-dev-per-prod slot (and locking prod
|
||
// mutates its protection rules), so require admin of the parent regardless of the lock flags —
|
||
// mirrors attach/detach, which are prod-admin gated. Without this a non-admin forker could
|
||
// claim the dev slot (and, without member copy, prod admins might not even see it to detach).
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
ensure_no_existing_dev_workspace(&db, &parent_workspace_id).await?;
|
||
}
|
||
|
||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||
|
||
if nw.is_dev_workspace {
|
||
// The checks above ran outside a transaction, so the parent's eligibility and the chain's
|
||
// labels could have changed under us: re-decide both here, under the pairing lock.
|
||
lock_dev_pairing(&mut tx, &[&parent_workspace_id]).await?;
|
||
ensure_dev_parent_can_host_dev(&mut *tx, &parent_workspace_id).await?;
|
||
ensure_no_existing_dev_workspace(&mut *tx, &parent_workspace_id).await?;
|
||
reject_dev_label_taken_in_chain(
|
||
&mut *tx,
|
||
&parent_workspace_id,
|
||
&nw.id,
|
||
false,
|
||
dev_workspace_label.as_deref(),
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
let forked_id = nw.id;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO workspace
|
||
(id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)
|
||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||
forked_id,
|
||
nw.name,
|
||
authed.email,
|
||
parent_workspace_id,
|
||
nw.is_dev_workspace,
|
||
dev_workspace_label,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_settings
|
||
(workspace_id, color)
|
||
VALUES ($1, $2)",
|
||
forked_id,
|
||
nw.color,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Optionally bring the parent's members into the fork (a shared dev env). Dev-only: it's part of the
|
||
// dev-workspace feature (and the frontend only offers it there), so the backend enforces it rather
|
||
// than trusting the client — copying the parent's whole team into an ordinary throwaway fork isn't
|
||
// intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before
|
||
// the explicit creator insert below so the creator (a parent member) is copied with full metadata
|
||
// (operator/role/is_service_account/added_via), not the bare row the insert alone would leave.
|
||
if nw.copy_members && nw.is_dev_workspace {
|
||
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?;
|
||
}
|
||
|
||
// Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent
|
||
// member). No-op when copy_members already brought their full row.
|
||
sqlx::query!(
|
||
"INSERT INTO usr
|
||
(workspace_id, email, username, is_admin)
|
||
SELECT $1, email, username, is_admin FROM usr
|
||
WHERE workspace_id = $3 AND email = $2
|
||
ON CONFLICT DO NOTHING
|
||
",
|
||
forked_id,
|
||
authed.email,
|
||
parent_workspace_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// The pointers this fork writes to the parent's data tables stay invisible until it commits, so
|
||
// a rename of one of them cannot carry them. Holding the parent's settings row makes such a
|
||
// rename wait for this commit, and makes the copy below read one that committed first.
|
||
sqlx::query("SELECT 1 FROM workspace_settings WHERE workspace_id = $1 FOR SHARE")
|
||
.bind(&parent_workspace_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Clone all data from the parent workspace using Rust implementation
|
||
if let Err(e) =
|
||
clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await
|
||
{
|
||
// A genuine `\u0000` in a source `json` value (`app_version.value` /
|
||
// `flow_version.schema`) aborts the clone when it is re-encoded to jsonb:
|
||
// Postgres raises 22P05 with only "unsupported Unicode escape sequence"
|
||
// and no hint at which item. Pinpoint the offenders so the user can fix
|
||
// them — re-saving strips the NUL, and the usual source is a binary file
|
||
// (e.g. `.DS_Store`) accidentally bundled into a raw app.
|
||
if is_unsupported_unicode_escape(&e) {
|
||
drop(tx); // release the poisoned connection before probing on the pool
|
||
let locations = find_nul_escape_locations(&db, &parent_workspace_id).await;
|
||
let where_clause = if locations.is_empty() {
|
||
"The offending item could not be pinpointed — check recently edited apps and flows."
|
||
.to_string()
|
||
} else {
|
||
format!("Offending item(s):\n - {}", locations.join("\n - "))
|
||
};
|
||
return Err(Error::BadRequest(format!(
|
||
"Cannot fork workspace '{parent_workspace_id}': an item contains a NUL character \
|
||
(\\u0000) that Postgres cannot store as jsonb. Re-save the item to remove it \
|
||
(the editor strips NUL automatically), or delete the offending binary/character \
|
||
from its source (often a file like .DS_Store bundled into a raw app). {where_clause}"
|
||
)));
|
||
}
|
||
return Err(e);
|
||
}
|
||
|
||
// Clone triggers and schedules unconditionally, always with mode='disabled' /
|
||
// enabled=false. Disabled rows have no side effects (no listener
|
||
// attaches, no cron fires) so this is safe by construction. The user
|
||
// re-enables in the fork, with parent-conflict warnings on enable.
|
||
clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?;
|
||
|
||
// Update forked datatable settings to point to new databases
|
||
for fdt in &nw.forked_datatables {
|
||
apply_forked_datatable(&db, &mut tx, &authed, &parent_workspace_id, &forked_id, fdt)
|
||
.await?;
|
||
}
|
||
|
||
point_kept_datatables_at_parent(
|
||
&mut tx,
|
||
&parent_workspace_id,
|
||
&forked_id,
|
||
&nw.forked_datatables,
|
||
)
|
||
.await?;
|
||
|
||
// The settings clone copies the source's ducklake config verbatim — including a parent
|
||
// fork's own `fork_behavior` stamps. Sharing is a per-fork-creation choice, never
|
||
// inherited: reset any cloned stamps first, then apply this fork's requested list.
|
||
sqlx::query!(
|
||
r#"UPDATE workspace_settings
|
||
SET ducklake = jsonb_set(ducklake, '{ducklakes}', (
|
||
SELECT COALESCE(jsonb_object_agg(key, value - 'fork_behavior'), '{}'::jsonb)
|
||
FROM jsonb_each(ducklake->'ducklakes')
|
||
))
|
||
WHERE workspace_id = $1 AND jsonb_typeof(ducklake->'ducklakes') = 'object'"#,
|
||
&forked_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// Stamp the per-lake ducklake fork choice into the fork's own settings. Only the `shared`
|
||
// opt-out needs stamping — absent `fork_behavior` already means isolated (the default),
|
||
// so unlisted lakes and API callers that omit the field stay safe.
|
||
for lake in &nw.shared_ducklakes {
|
||
let stamped = sqlx::query_scalar!(
|
||
r#"UPDATE workspace_settings
|
||
SET ducklake = jsonb_set(ducklake, ARRAY['ducklakes', $2, 'fork_behavior'], '"shared"')
|
||
WHERE workspace_id = $1 AND ducklake->'ducklakes' ? $2
|
||
RETURNING 1 AS "one!""#,
|
||
&forked_id,
|
||
lake,
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?;
|
||
if stamped.is_none() {
|
||
return Err(Error::BadRequest(format!(
|
||
"cannot mark ducklake `{lake}` as shared: no such lake in the workspace settings"
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Lock the parent ("prod") so edits are funneled through this dev workspace.
|
||
let locked_prod = nw.is_dev_workspace && (nw.lock_prod_deploy || nw.lock_prod_forking);
|
||
if locked_prod {
|
||
lock_prod_workspace(
|
||
&mut tx,
|
||
&parent_workspace_id,
|
||
nw.lock_prod_deploy,
|
||
nw.lock_prod_forking,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.create_fork",
|
||
ActionKind::Create,
|
||
&forked_id,
|
||
Some(nw.name.as_str()),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
// A pre-creation lookup could have cached an EMPTY ancestor chain for this id, which
|
||
// would bypass ducklake fork isolation for the TTL. The same lookup could have cached the id
|
||
// as its own root workspace, which would make the fork's first jobs report themselves as their
|
||
// own environment instead of the parent.
|
||
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&forked_id);
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&forked_id);
|
||
|
||
if locked_prod {
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&parent_workspace_id);
|
||
}
|
||
|
||
Ok(format!("Created forked workspace {}", &forked_id))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct AttachDevWorkspace {
|
||
dev_workspace_id: String,
|
||
#[serde(default)]
|
||
lock_prod_deploy: bool,
|
||
#[serde(default)]
|
||
lock_prod_forking: bool,
|
||
/// Environment label for the attached dev workspace, e.g. 'dev' or 'staging'. None defaults to 'dev'.
|
||
#[serde(default)]
|
||
dev_workspace_label: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct DetachDevWorkspace {
|
||
dev_workspace_id: String,
|
||
}
|
||
|
||
/// Pair an existing standalone workspace to this workspace ("prod") as its dev workspace, without
|
||
/// cloning any data (both already exist). Sets the dev's parent to prod and, optionally,
|
||
/// locks prod against direct deployment.
|
||
async fn attach_dev_workspace(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(prod_w_id): Path<String>,
|
||
Json(req): Json<AttachDevWorkspace>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
// Attaching grafts the candidate (and its own fork subtree) under prod, so enforce the general
|
||
// depth limit on the deepest resulting node.
|
||
let candidate_height =
|
||
windmill_common::workspaces::fork_subtree_height(&db, &req.dev_workspace_id).await?;
|
||
enforce_fork_depth(&db, &prod_w_id, candidate_height).await?;
|
||
|
||
// Attaching reparents a workspace under prod (one dev per prod, admin-gated) and it then draws
|
||
// prod's plan, so hold it to the same premium requirement as creating a fork. Only enforce the
|
||
// per-seat count when the attach actually adds a new workspace to the family: re-designating a
|
||
// workspace already under this root as its dev doesn't increase the descendant count.
|
||
#[cfg(feature = "cloud")]
|
||
if *CLOUD_HOSTED {
|
||
let root = require_cloud_fork_premium(&db, &prod_w_id).await?;
|
||
// Only count against the cap when this attach adds workspaces to the family (candidate not
|
||
// already under this root). The candidate may itself have child forks, so reserve slots for its
|
||
// whole incoming subtree (the candidate + its descendants), not just one.
|
||
if windmill_common::workspaces::get_billing_workspace_id(&db, &req.dev_workspace_id).await?
|
||
!= root
|
||
{
|
||
let incoming =
|
||
1 + windmill_common::workspaces::count_workspace_forks(&db, &req.dev_workspace_id)
|
||
.await?;
|
||
enforce_cloud_fork_count(&db, &root, incoming).await?;
|
||
}
|
||
}
|
||
|
||
let dev_w_id = req.dev_workspace_id;
|
||
if dev_w_id == prod_w_id {
|
||
return Err(Error::BadRequest(
|
||
"A workspace cannot be its own dev workspace".to_string(),
|
||
));
|
||
}
|
||
|
||
// The id is interpolated into a `wm-fork/<branch>/<id>` branch name like any fork.
|
||
validate_dev_workspace_id(&dev_w_id)?;
|
||
let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?;
|
||
let dev = sqlx::query!(
|
||
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
|
||
&dev_w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?;
|
||
|
||
if dev.deleted {
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace {} is archived",
|
||
dev_w_id
|
||
)));
|
||
}
|
||
// A candidate that already belongs to a DIFFERENT parent can't be attached. A candidate already
|
||
// parented to this prod is allowed: it's the recovery path after renaming a dev workspace (the
|
||
// rename keeps the parent but drops the dev flag), and re-designating an existing fork of this
|
||
// prod as its dev.
|
||
if dev
|
||
.parent_workspace_id
|
||
.as_deref()
|
||
.is_some_and(|p| p != prod_w_id)
|
||
{
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace {} is already a fork or dev workspace of another workspace",
|
||
dev_w_id
|
||
)));
|
||
}
|
||
let prod_exists = sqlx::query_scalar!(
|
||
r#"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1) AS "exists!""#,
|
||
&prod_w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?;
|
||
if !prod_exists {
|
||
return Err(Error::NotFound(format!(
|
||
"Workspace {} not found",
|
||
prod_w_id
|
||
)));
|
||
}
|
||
// Prod may be a root workspace or another dev workspace (a dev of a dev); a throwaway fork
|
||
// can't host one.
|
||
ensure_dev_parent_can_host_dev(&db, &prod_w_id).await?;
|
||
reject_attach_cycle(&db, &prod_w_id, &dev_w_id).await?;
|
||
|
||
// The attached workspace keeps its own sync repos and prod keeps its config; the label branch
|
||
// must not collide with either side's tracked branch.
|
||
reject_dev_label_matching_tracked_branch(
|
||
&db,
|
||
dev_workspace_label.as_deref(),
|
||
&[&prod_w_id, &dev_w_id],
|
||
)
|
||
.await?;
|
||
// The candidate keeps its own subtree, so its dev descendants keep their labels and join the
|
||
// chain alongside it.
|
||
reject_dev_label_taken_in_chain(
|
||
&mut *db.acquire().await?,
|
||
&prod_w_id,
|
||
&dev_w_id,
|
||
true,
|
||
dev_workspace_label.as_deref(),
|
||
)
|
||
.await?;
|
||
|
||
// The caller must be admin of the dev workspace too (or a superadmin).
|
||
let is_admin_of_dev = sqlx::query_scalar!(
|
||
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||
&dev_w_id,
|
||
&authed.email
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if !is_admin_of_dev && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||
return Err(Error::PermissionDenied(format!(
|
||
"Attaching workspace '{dev_w_id}' as a dev requires being an admin of it (or a superadmin)"
|
||
)));
|
||
}
|
||
|
||
// Deliberately below the admin-of-candidate check, unlike the cap enforcement above: the
|
||
// refusal names the candidate's plan, so running it earlier would tell any admin of any
|
||
// premium workspace whether an arbitrary workspace id is on a team plan.
|
||
#[cfg(feature = "cloud")]
|
||
if *CLOUD_HOSTED {
|
||
let root = windmill_common::workspaces::get_billing_workspace_id(&db, &prod_w_id).await?;
|
||
if windmill_common::workspaces::get_billing_workspace_id(&db, &dev_w_id).await? != root {
|
||
reject_attach_of_subscribed_workspace(&db, &dev_w_id).await?;
|
||
}
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
// Everything above ran outside a transaction, so prod's eligibility and the chain's labels could
|
||
// have changed under us: re-decide both here, under the pairing lock.
|
||
lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?;
|
||
// The candidate was read before the lock, and archiving it is one of the operations the lock
|
||
// serializes: re-read it, or the pairing lands on a workspace that is gone or has since been
|
||
// taken by another prod.
|
||
let dev = sqlx::query!(
|
||
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
|
||
&dev_w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.ok_or_else(|| Error::NotFound(format!("Workspace {} not found", dev_w_id)))?;
|
||
if dev.deleted {
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace {} is archived",
|
||
dev_w_id
|
||
)));
|
||
}
|
||
if dev
|
||
.parent_workspace_id
|
||
.as_deref()
|
||
.is_some_and(|p| p != prod_w_id)
|
||
{
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace {} is already a fork or dev workspace of another workspace",
|
||
dev_w_id
|
||
)));
|
||
}
|
||
ensure_dev_parent_can_host_dev(&mut *tx, &prod_w_id).await?;
|
||
reject_attach_cycle(&mut *tx, &prod_w_id, &dev_w_id).await?;
|
||
ensure_no_existing_dev_workspace(&mut *tx, &prod_w_id).await?;
|
||
reject_dev_label_taken_in_chain(
|
||
&mut *tx,
|
||
&prod_w_id,
|
||
&dev_w_id,
|
||
true,
|
||
dev_workspace_label.as_deref(),
|
||
)
|
||
.await?;
|
||
sqlx::query!(
|
||
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
|
||
&prod_w_id,
|
||
&dev_w_id,
|
||
dev_workspace_label,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
// The pair may have been compared as an arbitrary target before it was linked. Those rows were
|
||
// seeded one-way (everything `ahead`, direction guessed), which the tally must not inherit as
|
||
// its own history — drop them and the scan marker so the lineage diff starts from the tally.
|
||
// Under the pair lock, so a scan already in flight either loses its rows to the delete below
|
||
// or sees the new lineage link on its re-check and refuses.
|
||
lock_workspace_pair(&mut tx, &prod_w_id, &dev_w_id).await?;
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff_full_scan WHERE (source_workspace_id = $1 AND fork_workspace_id = $2)
|
||
OR (source_workspace_id = $2 AND fork_workspace_id = $1)",
|
||
&prod_w_id,
|
||
&dev_w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff WHERE (source_workspace_id = $1 AND fork_workspace_id = $2)
|
||
OR (source_workspace_id = $2 AND fork_workspace_id = $1)",
|
||
&prod_w_id,
|
||
&dev_w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
// Clearing the instance-alert opt-in here keeps the stored setting truthful for a workspace
|
||
// that becomes parent-managed: dispatch enforces the fork boundary on its own, but a lingering
|
||
// `true` would survive a later detach and would make the settings page submit a value the API
|
||
// rejects on a fork.
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET error_handler_fallback_to_instance_alerts = false WHERE workspace_id = $1",
|
||
&dev_w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
// The attached workspace is now parent-managed like any fork: its own
|
||
// auto-pull (and webhook), fork PRs, and promotion repos must not stay
|
||
// live — they'd keep pulling/pushing against its pre-attach tracked
|
||
// branch. Mirror the fork-creation copy: keep sync repos only, strip the
|
||
// parent-only fields, and delete any managed webhook after commit.
|
||
// `FOR UPDATE` like the other `git_sync` read-modify-writes: the hook ids
|
||
// collected here are deleted after commit, so a reconcile writing a new id in the
|
||
// gap would leave that hook live with nothing tracking it.
|
||
#[allow(unused_mut)]
|
||
let mut stripped_webhooks: Vec<(String, i64)> = Vec::new();
|
||
if let Some(git_sync) = sqlx::query_scalar!(
|
||
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||
&dev_w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.flatten()
|
||
{
|
||
if let Ok(mut settings) = serde_json::from_value::<WorkspaceGitSyncSettings>(git_sync) {
|
||
settings
|
||
.repositories
|
||
.retain(|r| !r.use_individual_branch.unwrap_or(false));
|
||
for r in settings.repositories.iter_mut() {
|
||
if let Some(hook) = r.auto_pull.as_ref().and_then(|a| a.webhook_id) {
|
||
stripped_webhooks.push((r.git_repo_resource_path.clone(), hook));
|
||
}
|
||
r.auto_pull = None;
|
||
r.fork_open_prs = false;
|
||
r.open_pr_error = None;
|
||
}
|
||
let serialized =
|
||
serde_json::to_value(&settings).map_err(|e| Error::internal_err(e.to_string()))?;
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
||
serialized,
|
||
&dev_w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
}
|
||
|
||
if req.lock_prod_deploy || req.lock_prod_forking {
|
||
lock_prod_workspace(
|
||
&mut tx,
|
||
&prod_w_id,
|
||
req.lock_prod_deploy,
|
||
req.lock_prod_forking,
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.attach_dev_workspace",
|
||
ActionKind::Update,
|
||
&prod_w_id,
|
||
Some(&dev_w_id),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
// The dev workspace's lineage just changed (none -> prod); drop its cached tag workspace so
|
||
// per-workspace job tags route to the prod family immediately rather than after the TTL. Tag
|
||
// resolution walks ancestors, so its own forks resolve through it and must be dropped too.
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id);
|
||
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id)
|
||
.await
|
||
.unwrap_or_default()
|
||
{
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&id);
|
||
}
|
||
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
|
||
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
|
||
}
|
||
// Best-effort: the hooks captured before the strip above are unreachable now
|
||
// (their auto_pull is gone), so remove them from GitHub.
|
||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||
for (path, hook_id) in stripped_webhooks {
|
||
if let Ok(url) =
|
||
windmill_common::git_sync_ee::resolve_repo_url_interpolated(&db, &dev_w_id, &path).await
|
||
{
|
||
let _ =
|
||
windmill_common::git_sync_ee::delete_repo_webhook(&db, &dev_w_id, &url, hook_id)
|
||
.await;
|
||
}
|
||
}
|
||
// Drop the cached ancestor chains too — the workspace existed BEFORE the attach, so a
|
||
// cached empty chain reads as "not a fork" and its ducklake jobs would write the shared
|
||
// lake until the TTL. Descendants' chains also gained the new root.
|
||
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&dev_w_id);
|
||
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? {
|
||
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
|
||
}
|
||
// Same reparent invalidates the billing-workspace mapping so its usage meters to prod at once. The
|
||
// candidate can bring its own fork subtree, whose descendants had resolved their (now-stale) root
|
||
// to the candidate's old family; invalidate them too so they meter to prod without waiting out the
|
||
// 60s TTL. The tag cache is swept above: resolution walks the whole chain, not the immediate link.
|
||
#[cfg(feature = "cloud")]
|
||
{
|
||
windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id);
|
||
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? {
|
||
windmill_common::workspaces::invalidate_billing_workspace_cache(&id);
|
||
}
|
||
}
|
||
|
||
if req.lock_prod_deploy || req.lock_prod_forking {
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id);
|
||
}
|
||
|
||
Ok(format!(
|
||
"Attached {} as dev workspace of {}",
|
||
dev_w_id, prod_w_id
|
||
))
|
||
}
|
||
|
||
/// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the
|
||
/// prod lock. Whether `parent_workspace_id` is kept depends on the workspace's origin (see the
|
||
/// UPDATE below): a genuine fork stays a fork, a standalone workspace returns to standalone.
|
||
async fn detach_dev_workspace(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(prod_w_id): Path<String>,
|
||
Json(req): Json<DetachDevWorkspace>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let dev_w_id = req.dev_workspace_id;
|
||
|
||
let mut tx = db.begin().await?;
|
||
// Under the pairing lock, so a dev workspace cannot appear beneath this one between the check
|
||
// below and the update.
|
||
lock_dev_pairing(&mut tx, &[&prod_w_id, &dev_w_id]).await?;
|
||
let is_dev_of_prod = sqlx::query_scalar!(
|
||
r#"SELECT EXISTS(
|
||
SELECT 1 FROM workspace
|
||
WHERE id = $1 AND parent_workspace_id = $2 AND is_dev_workspace
|
||
)"#,
|
||
&dev_w_id,
|
||
&prod_w_id
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if !is_dev_of_prod {
|
||
return Err(Error::BadRequest(format!(
|
||
"{} is not the dev workspace of {}",
|
||
dev_w_id, prod_w_id
|
||
)));
|
||
}
|
||
reject_stranding_nested_dev(&mut *tx, &dev_w_id, DevTeardown::Detach).await?;
|
||
|
||
// A wm-fork- workspace re-designated as dev returns to being a plain fork
|
||
// (keeps its parent); a standalone workspace that was attached returns to
|
||
// being standalone — with the parent kept it would still classify as a
|
||
// fork and deploy to wm-fork/** branches forever.
|
||
sqlx::query!(
|
||
"UPDATE workspace SET is_dev_workspace = false,
|
||
parent_workspace_id = CASE WHEN id LIKE 'wm-fork-%' THEN parent_workspace_id ELSE NULL END
|
||
WHERE id = $1",
|
||
&dev_w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
// Only one dev per prod, so detaching it means prod no longer has a dev: drop the lock rule.
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||
&prod_w_id,
|
||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.detach_dev_workspace",
|
||
ActionKind::Update,
|
||
&prod_w_id,
|
||
Some(&dev_w_id),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id);
|
||
// The parent link may just have been cleared (standalone workspace that was
|
||
// attached): drop the caches that resolved it, mirroring attach.
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id);
|
||
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&dev_w_id);
|
||
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? {
|
||
windmill_common::workspaces::invalidate_fork_ancestor_chain_cache(&id);
|
||
// Tag resolution walks ancestors, so a descendant's cached tag workspace resolved through
|
||
// the workspace whose dev flag just changed.
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&id);
|
||
}
|
||
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
|
||
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
|
||
}
|
||
#[cfg(feature = "cloud")]
|
||
{
|
||
windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id);
|
||
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? {
|
||
windmill_common::workspaces::invalidate_billing_workspace_cache(&id);
|
||
}
|
||
}
|
||
|
||
Ok(format!(
|
||
"Detached dev workspace {} from {}",
|
||
dev_w_id, prod_w_id
|
||
))
|
||
}
|
||
|
||
async fn edit_workspace(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||
Json(ew): Json<EditWorkspace>,
|
||
) -> Result<String> {
|
||
require_admin(is_admin, &username)?;
|
||
let mut tx = db.begin().await?;
|
||
sqlx::query!(
|
||
"UPDATE workspace SET name = $1, owner = $2 WHERE id = $3",
|
||
ew.name,
|
||
ew.owner,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.update",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Updated workspace {}", &w_id))
|
||
}
|
||
|
||
/// Archive a workspace: disable schedules, cancel jobs, and mark as deleted.
|
||
/// Returns (schedules_disabled_count, jobs_canceled_count).
|
||
pub(crate) async fn archive_workspace_impl(
|
||
db: &DB,
|
||
w_id: &str,
|
||
username: &str,
|
||
// When archiving a dev workspace, its parent prod. The pairing teardown (clear is_dev + drop the
|
||
// prod's lock) is folded into the same transaction as `deleted = true` so it's atomic with the
|
||
// archive — a later failure can't strand a half-archived dev that's still flagged/locked.
|
||
dev_lock_parent: Option<&str>,
|
||
) -> Result<(usize, usize, usize)> {
|
||
// Step 1: Disable all schedules and clear their queued jobs
|
||
let mut tx = db.begin().await?;
|
||
// Unconditionally, before reading any pairing state: whether this workspace is a dev, and whether
|
||
// it has one, is exactly what a concurrent attach changes, so gating the lock on the caller's
|
||
// `dev_lock_parent` would skip it on the strength of the value the race invalidates.
|
||
lock_dev_pairing(&mut tx, &[w_id]).await?;
|
||
let dev_parent = sqlx::query_scalar!(
|
||
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||
w_id
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.flatten();
|
||
// The caller resolved this before the lock and authorized against it — its prod admin check, and
|
||
// the pairing teardown below, are both answers to that value. Refuse rather than act on a pairing
|
||
// nobody checked.
|
||
if dev_parent.as_deref() != dev_lock_parent {
|
||
return Err(Error::BadRequest(format!(
|
||
"The dev pairing of {w_id} changed while it was being archived. Retry."
|
||
)));
|
||
}
|
||
if dev_parent.is_some() {
|
||
// Archiving a dev workspace clears its dev flag, so it must not strand a dev workspace of
|
||
// its own.
|
||
reject_stranding_nested_dev(&mut *tx, w_id, DevTeardown::Archive).await?;
|
||
}
|
||
let disabled_schedules = sqlx::query_scalar!(
|
||
"UPDATE schedule SET enabled = false WHERE workspace_id = $1 AND enabled = true RETURNING path",
|
||
w_id
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
|
||
let schedules_count = disabled_schedules.len();
|
||
tracing::info!(
|
||
"Disabled {} schedules in workspace {}",
|
||
schedules_count,
|
||
w_id
|
||
);
|
||
|
||
// Clear all schedule-related jobs using the existing clear_schedule function
|
||
for schedule_path in &disabled_schedules {
|
||
windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, w_id).await?;
|
||
}
|
||
|
||
// Delete non-session tokens scoped to this workspace
|
||
let deleted_tokens = sqlx::query_scalar!(
|
||
"DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix",
|
||
w_id
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
|
||
tracing::info!(
|
||
"Deleted {} non-session tokens in workspace {}",
|
||
deleted_tokens.len(),
|
||
w_id
|
||
);
|
||
|
||
// Mark workspace as archived
|
||
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", w_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
if let Some(prod) = dev_lock_parent {
|
||
// Dissolve the dev pairing atomically with the archive: clear the canonical-dev flag (so the
|
||
// archived row no longer occupies the parent's one-dev slot), and drop the prod's lock unless a
|
||
// replacement dev already holds it (NOT EXISTS sees the just-cleared flag within this tx, so the
|
||
// row being archived doesn't count).
|
||
sqlx::query!(
|
||
"UPDATE workspace SET is_dev_workspace = false WHERE id = $1",
|
||
w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM workspace
|
||
WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false
|
||
)",
|
||
prod,
|
||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
tx.commit().await?;
|
||
|
||
// Step 2: Get all remaining queued jobs for this workspace (non-schedule jobs)
|
||
let jobs_to_cancel =
|
||
sqlx::query_scalar!("SELECT id FROM v2_job_queue WHERE workspace_id = $1", w_id)
|
||
.fetch_all(db)
|
||
.await?;
|
||
|
||
let jobs_count = jobs_to_cancel.len();
|
||
tracing::info!(
|
||
"Found {} remaining jobs to cancel in workspace {}",
|
||
jobs_count,
|
||
w_id
|
||
);
|
||
|
||
// Step 3: Cancel all remaining jobs using the existing cancel_jobs function
|
||
let canceled_count = if !jobs_to_cancel.is_empty() {
|
||
let axum::Json(canceled_jobs) = windmill_api_jobs::cancel_jobs(
|
||
jobs_to_cancel,
|
||
db,
|
||
username,
|
||
w_id,
|
||
false, // force_cancel
|
||
)
|
||
.await?;
|
||
|
||
let count = canceled_jobs.len();
|
||
tracing::info!("Canceled {} jobs in workspace {}", count, w_id);
|
||
count
|
||
} else {
|
||
0
|
||
};
|
||
|
||
Ok((schedules_count, canceled_count, deleted_tokens.len()))
|
||
}
|
||
|
||
async fn archive_workspace(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
authed: ApiAuthed,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
// CE caps the number of archived (soft-deleted) workspaces so archiving can't be used to
|
||
// stockpile hidden workspaces. Enforced here so a second archive is refused up front.
|
||
#[cfg(not(feature = "enterprise"))]
|
||
_check_nb_of_archived_workspaces(&db).await?;
|
||
|
||
// If this is an attached dev workspace, archiving it leaves the prod with no active dev (the
|
||
// unique index and user_workspaces both ignore deleted=true), so clear the prod's
|
||
// dev_workspace_lock too. Gate it on prod-admin since it removes prod's protection rule (mirrors
|
||
// detach/delete) — a dev-admin who isn't a prod-admin must not be able to unlock prod this way.
|
||
let dev_lock_parent: Option<String> = sqlx::query_scalar!(
|
||
"SELECT parent_workspace_id FROM workspace WHERE id = $1 AND is_dev_workspace",
|
||
&w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.flatten();
|
||
if let Some(ref prod) = dev_lock_parent {
|
||
let is_prod_admin = sqlx::query_scalar!(
|
||
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||
prod,
|
||
&authed.email
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if !is_prod_admin && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||
return Err(Error::PermissionDenied(format!(
|
||
"Archiving dev workspace '{w_id}' requires being an admin of its parent prod workspace '{prod}' (or a superadmin)"
|
||
)));
|
||
}
|
||
}
|
||
|
||
// The dev pairing teardown (clear is_dev + drop the prod lock) runs inside archive_workspace_impl's
|
||
// transaction, atomically with `deleted = true` — including the guard that it strands no nested
|
||
// dev workspace, which only applies to a workspace that is itself a dev (`dev_lock_parent`): a
|
||
// root archived out from under its dev is the pre-existing shape and not this pairing's to police.
|
||
let (schedules_count, canceled_count, deleted_tokens_count) =
|
||
archive_workspace_impl(&db, &w_id, &authed.username, dev_lock_parent.as_deref()).await?;
|
||
|
||
// Audit log
|
||
let mut tx = db.begin().await?;
|
||
let mut audit_params = HashMap::new();
|
||
audit_params.insert("disabled_schedules", schedules_count.to_string());
|
||
audit_params.insert("canceled_jobs", canceled_count.to_string());
|
||
audit_params.insert("deleted_tokens", deleted_tokens_count.to_string());
|
||
let audit_params_refs: HashMap<&str, &str> =
|
||
audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.archive",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
Some(audit_params_refs.clone()),
|
||
)
|
||
.await?;
|
||
// Also record under the instance-level "admins" workspace so superadmins can
|
||
// discover who archived a workspace after it becomes hidden from the UI.
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.archive",
|
||
ActionKind::Update,
|
||
"admins",
|
||
Some(&w_id),
|
||
Some(audit_params_refs),
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
if let Some(prod) = dev_lock_parent {
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod);
|
||
// The teardown above cleared `is_dev_workspace`, which is what let this workspace keep its
|
||
// own id for tag routing; it and every fork resolving through it now land on an ancestor.
|
||
// Only a dev workspace reaches here, so archiving anything else needs no sweep.
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&w_id);
|
||
for id in windmill_common::workspaces::list_fork_descendants(&db, &w_id)
|
||
.await
|
||
.unwrap_or_default()
|
||
{
|
||
windmill_queue::tags::invalidate_fork_parent_cache(&id);
|
||
}
|
||
if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await {
|
||
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
|
||
}
|
||
}
|
||
|
||
Ok(format!(
|
||
"Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens",
|
||
&w_id, schedules_count, canceled_count, deleted_tokens_count
|
||
))
|
||
}
|
||
|
||
async fn leave_workspace(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
authed: ApiAuthed,
|
||
) -> Result<String> {
|
||
windmill_api_auth::forbid_job_token_account_destruction(&authed)?;
|
||
let mut tx = db.begin().await?;
|
||
// The membership is what made `u/<username>` mean this person. Leaving it behind in a tenant
|
||
// list would hand their data table access back on rejoin, or to whoever takes the name next.
|
||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||
&mut tx,
|
||
&w_id,
|
||
&format!("u/{}", authed.username),
|
||
)
|
||
.await?;
|
||
sqlx::query!(
|
||
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
|
||
&w_id,
|
||
&authed.email
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.leave",
|
||
ActionKind::Delete,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Left workspace {}", &w_id))
|
||
}
|
||
|
||
async fn unarchive_workspace(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
authed: ApiAuthed,
|
||
) -> Result<String> {
|
||
// Global route (unarchives any workspace by id) gated on the caller's own
|
||
// is_admin claim, so it must reject a job token — see require_instance_admin.
|
||
require_instance_admin(&authed)?;
|
||
|
||
// Unarchiving re-activates a soft-deleted workspace, so it must respect the
|
||
// same CE workspace-count cap as creating one. The archived workspace is
|
||
// deleted = true and thus excluded from the count until it is restored.
|
||
#[cfg(not(feature = "enterprise"))]
|
||
_check_nb_of_workspaces(&db).await?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.unarchive",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
// Also record under the instance-level "admins" workspace so superadmins keep
|
||
// a durable trail of who unarchived a workspace.
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.unarchive",
|
||
ActionKind::Update,
|
||
"admins",
|
||
Some(&w_id),
|
||
None,
|
||
)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(format!("Unarchived workspace {}", &w_id))
|
||
}
|
||
|
||
/// Whether the instance is configured to suppress the email notifications sent
|
||
/// when a user is invited or added to a workspace. Defaults to false (emails on).
|
||
async fn workspace_invite_emails_disabled(db: &DB) -> Result<bool> {
|
||
Ok(
|
||
windmill_common::global_settings::load_value_from_global_settings(
|
||
db,
|
||
DISABLE_WORKSPACE_INVITE_EMAILS_SETTING,
|
||
)
|
||
.await?
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false),
|
||
)
|
||
}
|
||
|
||
async fn invite_user(
|
||
ApiAuthed { username, is_admin, .. }: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Extension(webhook): Extension<WebhookShared>,
|
||
Path(w_id): Path<String>,
|
||
Json(mut nu): Json<NewWorkspaceInvite>,
|
||
) -> Result<(StatusCode, String)> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
#[cfg(not(feature = "enterprise"))]
|
||
if w_id == "admins" {
|
||
return Err(Error::BadRequest(
|
||
"The admins workspace is reserved for superadmins. Members cannot be added to it without an enterprise license.".to_string(),
|
||
));
|
||
}
|
||
|
||
nu.email = nu.email.to_lowercase();
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
if let Some(msg) =
|
||
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
|
||
{
|
||
return Err(Error::BadRequest(msg));
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let already_in_workspace = sqlx::query_scalar!(
|
||
"SELECT EXISTS (SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)",
|
||
&w_id,
|
||
nu.email
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
if already_in_workspace {
|
||
return Err(Error::BadRequest(format!(
|
||
"user with email {} already exists in workspace {}",
|
||
nu.email, w_id
|
||
)));
|
||
}
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_invite
|
||
(workspace_id, email, is_admin, operator)
|
||
VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email)
|
||
DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator",
|
||
&w_id,
|
||
nu.email,
|
||
nu.is_admin,
|
||
nu.operator
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
if !workspace_invite_emails_disabled(&db).await? {
|
||
send_email_if_possible(
|
||
&format!("Invited to Windmill's workspace: {w_id}"),
|
||
&format!(
|
||
"You have been granted access to Windmill's workspace {w_id}
|
||
|
||
If you do not have an account on {}, login with SSO or ask an admin to create an account for you.",
|
||
(**BASE_URL.load()).clone()
|
||
),
|
||
&nu.email,
|
||
);
|
||
}
|
||
|
||
webhook.send_instance_event(InstanceEvent::UserInvitedWorkspace {
|
||
email: nu.email.clone(),
|
||
workspace: w_id,
|
||
});
|
||
|
||
Ok((
|
||
StatusCode::CREATED,
|
||
format!("user with email {} invited", nu.email),
|
||
))
|
||
}
|
||
|
||
/// Non-admin path for `add_user`: the creator of a fork may bring collaborators into the fork they
|
||
/// created, so a team can work on it without an admin of the fork having to step in. The grant is
|
||
/// deliberately narrow, because a fork holds a full clone of its parent (secrets included) and the
|
||
/// creator may be an ordinary developer:
|
||
/// - only on a fork they created, never on a root workspace;
|
||
/// - only as a developer, so it can never mint an admin (nor an operator, which would need the
|
||
/// workspace's operator settings to be meaningful);
|
||
/// - only for someone who is already a developer or admin of the parent, so pulling them into the
|
||
/// fork cannot widen who can read the parent's data.
|
||
///
|
||
/// Anything outside those bounds stays an admin's call. Returns the username the new member must be
|
||
/// given in the fork.
|
||
async fn authorize_fork_owner_add_user(
|
||
db: &DB,
|
||
w_id: &str,
|
||
authed: &ApiAuthed,
|
||
nu: &NewWorkspaceUser,
|
||
) -> Result<String> {
|
||
let parent = windmill_common::workspaces::fork_owned_by(db, w_id, &authed.email)
|
||
.await?
|
||
.ok_or_else(|| Error::RequireAdmin(authed.username.clone()))?;
|
||
|
||
if nu.is_admin || nu.operator {
|
||
return Err(Error::PermissionDenied(format!(
|
||
"as the creator of fork {w_id} you can only add members as developers; ask an admin of \
|
||
{w_id} for any other role"
|
||
)));
|
||
}
|
||
|
||
let parent_username = sqlx::query_scalar!(
|
||
"SELECT username FROM usr
|
||
WHERE workspace_id = $1 AND email = $2 AND NOT operator AND NOT disabled",
|
||
parent,
|
||
nu.email,
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let Some(parent_username) = parent_username else {
|
||
return Err(Error::PermissionDenied(format!(
|
||
"as the creator of fork {w_id} you can only add developers or admins of its parent \
|
||
workspace {parent}; {} is not one, so only an admin of {w_id} can add them",
|
||
nu.email
|
||
)));
|
||
};
|
||
|
||
// Ownership of a `u/<username>/` path is decided by the username alone, and the fork holds a
|
||
// clone of every such path from the parent. Seating the new member on a username other than
|
||
// their own would therefore hand them that parent user's cloned scripts, variables and secrets
|
||
// — so their parent username is the only one they may be given here, whatever the caller asked
|
||
// for (`add_user` otherwise lets the caller choose it when AUTOMATE_USERNAME_CREATION is off).
|
||
if nu
|
||
.username
|
||
.as_deref()
|
||
.is_some_and(|u| !u.is_empty() && u != parent_username)
|
||
{
|
||
return Err(Error::PermissionDenied(format!(
|
||
"as the creator of fork {w_id} you cannot choose the username of a member you add; {} \
|
||
joins as '{parent_username}', the username they already have in {parent}",
|
||
nu.email
|
||
)));
|
||
}
|
||
|
||
Ok(parent_username)
|
||
}
|
||
|
||
async fn add_user(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Extension(webhook): Extension<WebhookShared>,
|
||
Path(w_id): Path<String>,
|
||
Json(mut nu): Json<NewWorkspaceUser>,
|
||
) -> Result<(StatusCode, String)> {
|
||
#[cfg(not(feature = "enterprise"))]
|
||
if w_id == "admins" {
|
||
return Err(Error::BadRequest(
|
||
"The admins workspace is reserved for superadmins. Members cannot be added to it without an enterprise license.".to_string(),
|
||
));
|
||
}
|
||
|
||
nu.email = nu.email.to_lowercase();
|
||
|
||
let fork_owner_username = if !authed.is_admin {
|
||
Some(authorize_fork_owner_add_user(&db, &w_id, &authed, &nu).await?)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
#[cfg(feature = "enterprise")]
|
||
if let Some(msg) =
|
||
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
|
||
{
|
||
return Err(Error::BadRequest(msg));
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let already_exists_email = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)",
|
||
&w_id,
|
||
nu.email,
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
if already_exists_email {
|
||
return Err(Error::BadRequest(format!(
|
||
"user with email {} already exists in workspace {}",
|
||
nu.email, w_id
|
||
)));
|
||
}
|
||
|
||
let automate_username_creation = sqlx::query_scalar!(
|
||
"SELECT value FROM global_settings WHERE name = $1",
|
||
AUTOMATE_USERNAME_CREATION_SETTING,
|
||
)
|
||
.fetch_optional(&mut *tx)
|
||
.await?
|
||
.map(|v| v.as_bool())
|
||
.flatten()
|
||
.unwrap_or(true);
|
||
|
||
let username = if let Some(username) = fork_owner_username {
|
||
username
|
||
} else if automate_username_creation {
|
||
if nu.username.is_some() && nu.username.unwrap().len() > 0 {
|
||
return Err(Error::BadRequest(
|
||
"username is not allowed when username creation is automated".to_string(),
|
||
));
|
||
}
|
||
get_instance_username_or_create_pending(&mut tx, &nu.email).await?
|
||
} else {
|
||
let username = nu
|
||
.username
|
||
.ok_or(Error::BadRequest("username is required".to_string()))?;
|
||
|
||
if !VALID_USERNAME.is_match(&username) {
|
||
return Err(windmill_common::error::Error::BadRequest(format!(
|
||
"Usermame can only contain alphanumeric characters and underscores and must start with a letter"
|
||
)));
|
||
}
|
||
|
||
username
|
||
};
|
||
|
||
sqlx::query!(
|
||
"INSERT INTO usr
|
||
(workspace_id, email, username, is_admin, operator)
|
||
VALUES ($1, $2, $3, $4, $5)",
|
||
&w_id,
|
||
nu.email,
|
||
username,
|
||
nu.is_admin,
|
||
nu.operator
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2",
|
||
&w_id,
|
||
nu.email
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
sqlx::query_as!(
|
||
Group,
|
||
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||
&w_id,
|
||
username,
|
||
"all",
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"users.add_to_workspace",
|
||
ActionKind::Create,
|
||
&w_id,
|
||
Some(&nu.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::User { email: nu.email.clone() },
|
||
Some(format!("Added user '{}' to workspace", &nu.email)),
|
||
true,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
if !workspace_invite_emails_disabled(&db).await? {
|
||
send_email_if_possible(
|
||
&format!("Added to Windmill's workspace: {w_id}"),
|
||
&format!(
|
||
"You have been granted access to Windmill's workspace {w_id} by {}
|
||
|
||
If you do not have an account on {}, login with SSO or ask an admin to create an account for you.",
|
||
authed.email,
|
||
(**BASE_URL.load()).clone()
|
||
),
|
||
&nu.email,
|
||
);
|
||
}
|
||
|
||
webhook.send_instance_event(InstanceEvent::UserAddedWorkspace {
|
||
workspace: w_id.clone(),
|
||
email: nu.email.clone(),
|
||
});
|
||
|
||
Ok((
|
||
StatusCode::CREATED,
|
||
format!("user with email {} added", nu.email),
|
||
))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct NewServiceAccount {
|
||
pub username: String,
|
||
#[serde(default)]
|
||
pub is_admin: bool,
|
||
#[serde(default = "default_true")]
|
||
pub operator: bool,
|
||
#[serde(default)]
|
||
pub add_to_deployers: bool,
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
async fn create_service_account(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(nu): Json<NewServiceAccount>,
|
||
) -> Result<(StatusCode, String)> {
|
||
crate::workspaces_oss::create_service_account(authed, db, w_id, nu).await
|
||
}
|
||
|
||
async fn delete_invite(
|
||
ApiAuthed { username, is_admin, .. }: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(nu): Json<NewWorkspaceInvite>,
|
||
) -> Result<(StatusCode, String)> {
|
||
require_admin(is_admin, &username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_invite WHERE
|
||
workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4",
|
||
&w_id,
|
||
nu.email,
|
||
nu.is_admin,
|
||
nu.operator
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok((
|
||
StatusCode::CREATED,
|
||
format!("invite to email {} deleted", nu.email),
|
||
))
|
||
}
|
||
|
||
async fn exists_username(
|
||
Extension(db): Extension<DB>,
|
||
Json(vu): Json<ValidateUsername>,
|
||
) -> Result<String> {
|
||
let exists = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)",
|
||
vu.username,
|
||
vu.id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(true);
|
||
|
||
if exists {
|
||
return Err(Error::BadRequest("username already taken".to_string()));
|
||
}
|
||
|
||
Ok("valid username".to_string())
|
||
}
|
||
|
||
async fn get_workspace_name(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> Result<String> {
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let workspace = sqlx::query_scalar!("SELECT name FROM workspace WHERE id = $1", &w_id)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok(workspace)
|
||
}
|
||
|
||
async fn get_dependency_map(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<Vec<DependencyMap>> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let dmap = sqlx::query_as!(
|
||
DependencyMap,
|
||
"
|
||
SELECT workspace_id, importer_path, importer_kind::text, imported_path, importer_node_id
|
||
FROM dependency_map WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok(Json(dmap))
|
||
}
|
||
|
||
#[axum::debug_handler]
|
||
async fn rebuild_dependency_map(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
authed: ApiAuthed,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
if *CLOUD_HOSTED {
|
||
return Err(Error::BadRequest("Disabled on Cloud".into()));
|
||
}
|
||
ScopedDependencyMap::rebuild_map(&w_id, &db).await
|
||
}
|
||
|
||
#[axum::debug_handler]
|
||
async fn get_dependents(
|
||
Extension(db): Extension<DB>,
|
||
Path((w_id, imported_path)): Path<(String, String)>,
|
||
_authed: ApiAuthed,
|
||
) -> JsonResult<Vec<DependencyDependent>> {
|
||
tracing::debug!(
|
||
workspace_id = %w_id,
|
||
imported_path = %imported_path,
|
||
"API: Getting dependents for imported path"
|
||
);
|
||
|
||
let dependents = ScopedDependencyMap::get_dependents(&imported_path, &w_id, &db).await?;
|
||
|
||
tracing::debug!(
|
||
workspace_id = %w_id,
|
||
imported_path = %imported_path,
|
||
dependents_count = dependents.len(),
|
||
"API: Found dependents: {:?}",
|
||
dependents
|
||
);
|
||
|
||
Ok(Json(dependents))
|
||
}
|
||
|
||
async fn get_imports(
|
||
Extension(db): Extension<DB>,
|
||
Path((w_id, importer_path)): Path<(String, String)>,
|
||
_authed: ApiAuthed,
|
||
) -> JsonResult<Vec<String>> {
|
||
tracing::debug!(
|
||
workspace_id = %w_id,
|
||
importer_path = %importer_path,
|
||
"API: Getting imports for importer path"
|
||
);
|
||
|
||
let imports = ScopedDependencyMap::get_imports(&importer_path, &w_id, &db).await?;
|
||
|
||
tracing::debug!(
|
||
workspace_id = %w_id,
|
||
importer_path = %importer_path,
|
||
imports_count = imports.len(),
|
||
"API: Found imports: {:?}",
|
||
imports
|
||
);
|
||
|
||
Ok(Json(imports))
|
||
}
|
||
|
||
#[derive(Serialize, Debug)]
|
||
struct DependentsAmount {
|
||
imported_path: String,
|
||
count: i64,
|
||
}
|
||
|
||
#[axum::debug_handler]
|
||
async fn get_dependents_amounts(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(imported_paths): Json<Vec<String>>,
|
||
) -> JsonResult<Vec<DependentsAmount>> {
|
||
tracing::debug!(
|
||
workspace_id = %w_id,
|
||
imported_paths = ?imported_paths,
|
||
"API: Getting dependents amounts for imported paths"
|
||
);
|
||
|
||
let results = sqlx::query_as!(
|
||
DependentsAmount,
|
||
r#"
|
||
SELECT
|
||
imported_path,
|
||
COUNT(DISTINCT importer_path) as "count!"
|
||
FROM dependency_map
|
||
WHERE workspace_id = $1 AND imported_path = ANY($2)
|
||
GROUP BY imported_path
|
||
"#,
|
||
w_id,
|
||
&imported_paths
|
||
)
|
||
.fetch_all(&db)
|
||
.await?;
|
||
|
||
tracing::debug!(
|
||
workspace_id = %w_id,
|
||
results_count = results.len(),
|
||
"API: Found dependents amounts: {:?}",
|
||
results
|
||
);
|
||
|
||
Ok(Json(results))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ChangeWorkspaceName {
|
||
new_name: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ChangeWorkspaceColor {
|
||
color: Option<String>,
|
||
}
|
||
|
||
async fn change_workspace_name(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(db): Extension<DB>,
|
||
Json(rw): Json<ChangeWorkspaceName>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace SET name = $1 WHERE id = $2",
|
||
&rw.new_name,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspace.change_workspace_name",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&authed.email),
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
// Trigger git sync for workspace name changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings { setting_type: "workspace_name".to_string() },
|
||
Some(format!("Workspace name updated to {}", &rw.new_name)),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("updated workspace name to {}", &rw.new_name))
|
||
}
|
||
|
||
async fn change_workspace_color(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(db): Extension<DB>,
|
||
Json(rw): Json<ChangeWorkspaceColor>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET color = $1 WHERE workspace_id = $2",
|
||
rw.color,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: "workspace_color".to_string() },
|
||
None,
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!(
|
||
"updated workspace color to {}",
|
||
rw.color.as_deref().unwrap_or("no color")
|
||
))
|
||
}
|
||
|
||
async fn get_usage(Extension(db): Extension<DB>, Path(w_id): Path<String>) -> Result<String> {
|
||
// On cloud, a fork's executions meter against its billing root, so report the root's usage here too;
|
||
// otherwise the free-execs indicator would show the fork's own (often 0) count while enforcement
|
||
// applies the root's shared quota. Gated on `*CLOUD_HOSTED` (not just the `cloud` feature, which is
|
||
// compiled into all EE builds): self-hosted doesn't meter usage this way. Off-fork it resolves to
|
||
// `w_id` itself anyway.
|
||
#[cfg(feature = "cloud")]
|
||
let w_id = if *CLOUD_HOSTED {
|
||
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await?
|
||
} else {
|
||
w_id
|
||
};
|
||
let usage = sqlx::query_scalar!(
|
||
"
|
||
SELECT usage.usage FROM usage
|
||
WHERE is_workspace = true
|
||
AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)
|
||
AND id = $1",
|
||
w_id
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
Ok(usage.to_string())
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct EditPublicAppRateLimitRequest {
|
||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||
}
|
||
|
||
async fn edit_public_app_rate_limit(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
authed: ApiAuthed,
|
||
Json(req): Json<EditPublicAppRateLimitRequest>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
|
||
req.public_app_execution_limit_per_minute,
|
||
&w_id
|
||
)
|
||
.execute(&db)
|
||
.await?;
|
||
|
||
// Cache is invalidated via DB trigger -> notify_event -> polling in main.rs
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() },
|
||
None,
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!(
|
||
"Updated public app rate limit for workspace: {}",
|
||
&w_id
|
||
))
|
||
}
|
||
|
||
// 5 minutes fallback TTL (in addition to event-based invalidation)
|
||
const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300;
|
||
|
||
pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32>> {
|
||
use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE;
|
||
|
||
let now = Utc::now().timestamp();
|
||
|
||
if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) {
|
||
if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS {
|
||
return Ok(rate_limit);
|
||
}
|
||
}
|
||
|
||
let result: Option<Option<i32>> = sqlx::query_scalar(
|
||
"SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1",
|
||
)
|
||
.bind(w_id)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
let rate_limit = result.flatten();
|
||
PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now));
|
||
Ok(rate_limit)
|
||
}
|
||
|
||
#[derive(Deserialize, Serialize)]
|
||
struct ChangeOperatorSettings {
|
||
#[serde(default)]
|
||
runs: bool,
|
||
#[serde(default)]
|
||
schedules: bool,
|
||
#[serde(default)]
|
||
resources: bool,
|
||
#[serde(default)]
|
||
variables: bool,
|
||
#[serde(default)]
|
||
assets: bool,
|
||
#[serde(default)]
|
||
triggers: bool,
|
||
#[serde(default)]
|
||
audit_logs: bool,
|
||
#[serde(default)]
|
||
groups: bool,
|
||
#[serde(default)]
|
||
folders: bool,
|
||
#[serde(default)]
|
||
workers: bool,
|
||
}
|
||
|
||
async fn update_operator_settings(
|
||
authed: ApiAuthed,
|
||
Path(w_id): Path<String>,
|
||
Extension(db): Extension<DB>,
|
||
Json(settings): Json<ChangeOperatorSettings>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
let settings_json = serde_json::json!(settings);
|
||
|
||
sqlx::query!(
|
||
"UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2",
|
||
settings_json,
|
||
&w_id
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
// Trigger git sync for operator settings changes
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
windmill_git_sync::DeployedObject::Settings {
|
||
setting_type: "operator_settings".to_string(),
|
||
},
|
||
Some("Operator settings updated".to_string()),
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok("Operator settings updated successfully".to_string())
|
||
}
|
||
|
||
// Protection Rules API endpoints
|
||
|
||
#[derive(Deserialize)]
|
||
struct CreateProtectionRuleRequest {
|
||
name: String,
|
||
rules: Vec<ProtectionRuleKind>,
|
||
bypass_groups: Vec<String>,
|
||
bypass_users: Vec<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct UpdateProtectionRuleRequest {
|
||
/// Renames the rule when it differs from the path name. Absent means "leave the name alone",
|
||
/// which is what a client that never sends the field gets.
|
||
name: Option<String>,
|
||
rules: Vec<ProtectionRuleKind>,
|
||
bypass_groups: Vec<String>,
|
||
bypass_users: Vec<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct ProtectionRulesetResponse {
|
||
pub workspace_id: String,
|
||
pub name: String,
|
||
pub rules: Vec<ProtectionRuleKind>,
|
||
pub bypass_groups: Vec<String>,
|
||
pub bypass_users: Vec<String>,
|
||
}
|
||
|
||
impl From<ProtectionRuleset> for ProtectionRulesetResponse {
|
||
fn from(value: ProtectionRuleset) -> Self {
|
||
let mut rules = vec![];
|
||
|
||
for rule in ProtectionRuleKind::iter() {
|
||
if value.rules.contains(rule.flag()) {
|
||
rules.push(rule)
|
||
}
|
||
}
|
||
|
||
ProtectionRulesetResponse {
|
||
rules,
|
||
workspace_id: value.workspace_id,
|
||
name: value.name,
|
||
bypass_groups: value.bypass_groups,
|
||
bypass_users: value.bypass_users,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// List all protection rules for a workspace
|
||
async fn list_protection_rules(
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<ProtectionRulesetResponse>> {
|
||
let rules = (*windmill_common::workspaces::get_protection_rules(&w_id, &db).await?).clone();
|
||
Ok(Json(
|
||
rules
|
||
.into_iter()
|
||
.map(ProtectionRulesetResponse::from)
|
||
.collect(),
|
||
))
|
||
}
|
||
|
||
/// Insert or replace a protection ruleset within an existing transaction. Unlike the
|
||
/// `create_protection_rule` handler (which rejects an existing name), this upserts, so it is safe to
|
||
/// call programmatically when designating a dev/prod pair. Callers MUST invalidate the
|
||
/// protection-rules cache (`invalidate_protection_rules_cache`) after the transaction commits.
|
||
async fn upsert_protection_rule(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
w_id: &str,
|
||
name: &str,
|
||
rules: ProtectionRules,
|
||
bypass_groups: &[String],
|
||
bypass_users: &[String],
|
||
) -> Result<()> {
|
||
sqlx::query!(
|
||
r#"
|
||
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (workspace_id, name)
|
||
DO UPDATE SET rules = EXCLUDED.rules,
|
||
bypass_groups = EXCLUDED.bypass_groups,
|
||
bypass_users = EXCLUDED.bypass_users
|
||
"#,
|
||
w_id,
|
||
name,
|
||
rules.bits(),
|
||
bypass_groups,
|
||
bypass_users,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Lock a prod workspace by applying the reserved dev-workspace lock rule with the selected
|
||
/// restrictions (block direct deployment and/or ad-hoc forking). Non-admins are then funneled
|
||
/// through the one dev workspace; admins bypass the rules (their existing escape hatch).
|
||
async fn lock_prod_workspace(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
prod_w_id: &str,
|
||
block_deploy: bool,
|
||
block_forking: bool,
|
||
) -> Result<()> {
|
||
let mut rules = Vec::new();
|
||
if block_deploy {
|
||
rules.push(ProtectionRuleKind::DisableDirectDeployment);
|
||
}
|
||
if block_forking {
|
||
rules.push(ProtectionRuleKind::DisableWorkspaceForking);
|
||
}
|
||
if rules.is_empty() {
|
||
return Ok(());
|
||
}
|
||
upsert_protection_rule(
|
||
tx,
|
||
prod_w_id,
|
||
DEV_WORKSPACE_LOCK_RULE_NAME,
|
||
ProtectionRules::from(&rules),
|
||
&[],
|
||
&[],
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Error out if `parent_w_id` already has an active (non-archived) dev workspace. Mirrors the
|
||
/// partial unique index `workspace_canonical_dev_idx` with a friendly message.
|
||
async fn ensure_no_existing_dev_workspace<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||
db: E,
|
||
parent_w_id: &str,
|
||
) -> Result<()> {
|
||
let existing = sqlx::query_scalar!(
|
||
"SELECT id FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
|
||
parent_w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
if let Some(existing) = existing {
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace '{}' already has a dev workspace ('{}'). Detach it before creating another.",
|
||
parent_w_id, existing
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A dev workspace pairs with a root workspace or — supported, though not the recommended shape —
|
||
/// with another dev workspace, giving a promotion chain (dev of dev -> dev -> prod). A throwaway
|
||
/// fork is never a valid prod: its deploys go to its own `wm-fork/**` branch and it is discarded
|
||
/// with its subtree, so a dev pinned under it has nowhere to promote to. Nor is an archived one,
|
||
/// which hosts nothing at all.
|
||
async fn ensure_dev_parent_can_host_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||
db: E,
|
||
parent_w_id: &str,
|
||
) -> Result<()> {
|
||
let parent = sqlx::query!(
|
||
r#"SELECT (parent_workspace_id IS NOT NULL) AS "is_fork!", is_dev_workspace, deleted
|
||
FROM workspace WHERE id = $1"#,
|
||
parent_w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
let Some(parent) = parent else {
|
||
return Ok(());
|
||
};
|
||
if parent.deleted {
|
||
return Err(Error::BadRequest(format!(
|
||
"Cannot create a dev workspace of '{}' because it is archived.",
|
||
parent_w_id
|
||
)));
|
||
}
|
||
if parent.is_fork && !parent.is_dev_workspace {
|
||
return Err(Error::BadRequest(format!(
|
||
"Cannot create a dev workspace of '{}' because it is a throwaway fork.",
|
||
parent_w_id
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Prod may be a dev workspace, so the candidate can sit ABOVE it in the tree — reparenting it below
|
||
/// prod would close a parent<->child cycle and hang every hierarchy walk. Prod itself is at depth 0
|
||
/// of the chain, so callers must have rejected `dev_w_id == prod_w_id` first.
|
||
///
|
||
/// `reject_dev_label_taken_in_chain` would also reject a cyclic pairing, since a cycle puts one
|
||
/// workspace in the chain twice and so always repeats a label. It reports it as a workspace clashing
|
||
/// with itself, which describes nothing the caller can act on — hence this, first.
|
||
async fn reject_attach_cycle<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||
db: E,
|
||
prod_w_id: &str,
|
||
dev_w_id: &str,
|
||
) -> Result<()> {
|
||
let would_cycle = sqlx::query_scalar!(
|
||
r#"WITH RECURSIVE chain AS (
|
||
SELECT id, parent_workspace_id, 0 AS depth FROM workspace WHERE id = $1
|
||
UNION ALL
|
||
SELECT w.id, w.parent_workspace_id, chain.depth + 1 FROM workspace w
|
||
JOIN chain ON w.id = chain.parent_workspace_id
|
||
WHERE chain.depth < 20
|
||
)
|
||
SELECT EXISTS(SELECT 1 FROM chain WHERE id = $2) AS "cycle!""#,
|
||
prod_w_id,
|
||
dev_w_id,
|
||
)
|
||
.fetch_one(db)
|
||
.await?;
|
||
if would_cycle {
|
||
return Err(Error::BadRequest(format!(
|
||
"Workspace {} is an ancestor of {} and cannot become its dev workspace",
|
||
dev_w_id, prod_w_id
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Serialize everything that makes or breaks a dev pairing: giving a workspace a dev workspace
|
||
/// (create, attach) and clearing one's dev flag (detach, archive). Each decides on state the others
|
||
/// mutate — whether a workspace already has a dev, still has one, or leaves a label free — so
|
||
/// unserialized they all pass their checks and commit a shape those checks exist to reject. Take it
|
||
/// before re-running them inside the mutating transaction; it releases on commit or rollback.
|
||
///
|
||
/// Locks every workspace the operation's own checks read: each seed, its ancestors, and the dev
|
||
/// workspaces beneath it. Locking just the endpoints is not enough — the label rule spans a whole
|
||
/// chain, so two operations a couple of hops apart would hold disjoint keys and both commit. Reading
|
||
/// the same set that is checked is what closes that: an attach splices two chains together and so
|
||
/// holds nodes from both, and any operation that could collide with it necessarily touches the
|
||
/// joined chain, hence shares a node. Acquired in id order, the only ordering rule that keeps two
|
||
/// overlapping sets from deadlocking.
|
||
///
|
||
/// Recomputed inside the transaction, but from a set that may already be stale — harmless, because
|
||
/// whoever made it stale is the operation holding the node this one is missing.
|
||
pub(crate) async fn lock_dev_pairing(
|
||
tx: &mut Transaction<'_, Postgres>,
|
||
seeds: &[&str],
|
||
) -> Result<()> {
|
||
let seeds: Vec<String> = seeds.iter().map(|s| s.to_string()).collect();
|
||
// Depth bounds are the cycle-safety backstop used by every other hierarchy walk.
|
||
let nodes = sqlx::query_scalar!(
|
||
r#"WITH RECURSIVE seeded AS (SELECT unnest($1::text[]) AS id),
|
||
up AS (
|
||
SELECT w.id, w.parent_workspace_id, 0 AS depth
|
||
FROM workspace w JOIN seeded s ON w.id = s.id
|
||
UNION ALL
|
||
SELECT w.id, w.parent_workspace_id, up.depth + 1
|
||
FROM workspace w JOIN up ON w.id = up.parent_workspace_id
|
||
WHERE up.depth < 20
|
||
),
|
||
down AS (
|
||
SELECT w.id, 0 AS depth FROM workspace w JOIN seeded s ON w.id = s.id
|
||
UNION ALL
|
||
SELECT w.id, down.depth + 1
|
||
FROM workspace w JOIN down ON w.parent_workspace_id = down.id
|
||
WHERE down.depth < 20 AND w.is_dev_workspace
|
||
)
|
||
SELECT id AS "id!" FROM (
|
||
SELECT id FROM seeded UNION SELECT id FROM up UNION SELECT id FROM down
|
||
) n ORDER BY id"#,
|
||
&seeds[..]
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?;
|
||
// One statement per node rather than a set-returning call: only a client-side loop actually
|
||
// guarantees the acquisition order the deadlock argument above rests on.
|
||
for node in nodes {
|
||
sqlx::query!(
|
||
"SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))",
|
||
node
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// What is about to clear `is_dev_workspace` on a workspace, which decides whether the workspace can
|
||
/// go on hosting a dev workspace of its own afterwards.
|
||
enum DevTeardown {
|
||
/// Keeps `parent_workspace_id` only for a `wm-fork-` workspace, which then reads as a throwaway
|
||
/// fork. A prefix-less workspace returns to standalone and hosts its dev exactly as before.
|
||
Detach,
|
||
/// Soft-deletes the workspace whatever its id looks like, so it hosts nothing afterwards.
|
||
Archive,
|
||
}
|
||
|
||
/// A nested dev workspace outlives whatever clears its parent's dev flag, and the parent is then a
|
||
/// shape that hosts no pairing: its settings tab offers no detach control, and `delete_workspace`
|
||
/// refuses a workspace that still has a dev child, so the pairing could never be undone. Reject the
|
||
/// teardown so it is done bottom-up instead.
|
||
async fn reject_stranding_nested_dev<'e, E: sqlx::Executor<'e, Database = Postgres>>(
|
||
db: E,
|
||
w_id: &str,
|
||
teardown: DevTeardown,
|
||
) -> Result<()> {
|
||
let action = match teardown {
|
||
DevTeardown::Detach => {
|
||
if !w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX) {
|
||
return Ok(());
|
||
}
|
||
"Detaching"
|
||
}
|
||
DevTeardown::Archive => "Archiving",
|
||
};
|
||
let nested = sqlx::query_scalar!(
|
||
"SELECT id FROM workspace
|
||
WHERE parent_workspace_id = $1 AND is_dev_workspace AND NOT deleted",
|
||
w_id
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
if let Some(nested) = nested {
|
||
return Err(Error::BadRequest(format!(
|
||
"{action} {w_id} would leave it unable to host a pairing, but it is the prod workspace \
|
||
of '{nested}'. Detach '{nested}' first."
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A dev workspace deploys to the branch named by its environment label, and every dev workspace in
|
||
/// a chain inherits the same git-sync repositories, so two of them sharing a label push to one
|
||
/// branch: each deploy clobbers the other environment, and the root's auto-pull routes that branch
|
||
/// to whichever dev it matches first. Require every dev workspace in the resulting chain to carry a
|
||
/// distinct label — the dev ancestors `new_dev_id` lands under, `new_dev_id` with `label`, and (when
|
||
/// it already exists and so keeps its own subtree) the dev workspaces it brings with it. Dev
|
||
/// workspaces only ever hang off a root or another dev (`ensure_dev_parent_can_host_dev`), so that
|
||
/// chain is linear and this is the whole of it.
|
||
async fn reject_dev_label_taken_in_chain(
|
||
db: &mut sqlx::PgConnection,
|
||
parent_w_id: &str,
|
||
new_dev_id: &str,
|
||
keeps_own_subtree: bool,
|
||
label: Option<&str>,
|
||
) -> Result<()> {
|
||
// Depth bounds are the cycle-safety backstop used by every other hierarchy walk.
|
||
let mut chain = sqlx::query!(
|
||
r#"WITH RECURSIVE ancestors AS (
|
||
SELECT id, parent_workspace_id, is_dev_workspace, dev_workspace_label, deleted,
|
||
0 AS depth
|
||
FROM workspace WHERE id = $1
|
||
UNION ALL
|
||
SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.dev_workspace_label,
|
||
w.deleted, ancestors.depth + 1
|
||
FROM workspace w JOIN ancestors ON w.id = ancestors.parent_workspace_id
|
||
WHERE ancestors.depth < 20
|
||
)
|
||
SELECT id AS "id!", dev_workspace_label FROM ancestors
|
||
WHERE is_dev_workspace AND NOT deleted"#,
|
||
parent_w_id
|
||
)
|
||
.fetch_all(&mut *db)
|
||
.await?
|
||
.into_iter()
|
||
.map(|r| {
|
||
(
|
||
r.id,
|
||
windmill_common::workspaces::dev_workspace_branch(r.dev_workspace_label.as_deref()),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
chain.push((
|
||
new_dev_id.to_string(),
|
||
windmill_common::workspaces::dev_workspace_branch(label),
|
||
));
|
||
if keeps_own_subtree {
|
||
// `depth > 0`: the candidate itself is already in the list above, carrying its new label.
|
||
chain.extend(
|
||
sqlx::query!(
|
||
r#"WITH RECURSIVE tree AS (
|
||
SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth
|
||
FROM workspace WHERE id = $1
|
||
UNION ALL
|
||
SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted,
|
||
tree.depth + 1
|
||
FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id
|
||
WHERE tree.depth < 20
|
||
)
|
||
SELECT id AS "id!", dev_workspace_label FROM tree
|
||
WHERE depth > 0 AND is_dev_workspace AND NOT deleted"#,
|
||
new_dev_id
|
||
)
|
||
.fetch_all(&mut *db)
|
||
.await?
|
||
.into_iter()
|
||
.map(|r| {
|
||
(
|
||
r.id,
|
||
windmill_common::workspaces::dev_workspace_branch(
|
||
r.dev_workspace_label.as_deref(),
|
||
),
|
||
)
|
||
}),
|
||
);
|
||
}
|
||
let mut by_branch: HashMap<String, String> = HashMap::new();
|
||
for (id, branch) in chain {
|
||
if let Some(other) = by_branch.insert(branch.clone(), id.clone()) {
|
||
return Err(Error::BadRequest(format!(
|
||
"'{other}' and '{id}' would both be '{branch}' workspaces in the same chain: dev \
|
||
workspaces in a chain share their git-sync repositories, so both would deploy to \
|
||
the '{branch}' branch. Use a different environment label."
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `dev_workspace_lock` is owned by the dev-workspace feature, which creates and removes it by name
|
||
/// (attach/detach/archive/delete). Reserve the name against creation and deletion so a user-managed
|
||
/// rule can't collide with the feature's name-based cleanup, and so detach stays the way a pairing's
|
||
/// lock is lifted. Updating it is deliberately allowed: relaxing the lock is an admin's call, and the
|
||
/// name is immutable on update so no collision can arise.
|
||
fn reject_reserved_rule_name(name: &str) -> Result<()> {
|
||
if name == DEV_WORKSPACE_LOCK_RULE_NAME {
|
||
return Err(Error::BadRequest(format!(
|
||
"'{}' is a reserved protection-rule name managed by the dev workspace feature",
|
||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Create a new protection rule
|
||
async fn create_protection_rule(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<CreateProtectionRuleRequest>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
reject_reserved_rule_name(&req.name)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// Check if rule with this name already exists
|
||
let exists = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||
&w_id,
|
||
&req.name
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
if exists {
|
||
return Err(Error::BadRequest(format!(
|
||
"Protection rule with name '{}' already exists",
|
||
req.name
|
||
)));
|
||
}
|
||
|
||
// Insert the new rule
|
||
sqlx::query!(
|
||
r#"
|
||
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
"#,
|
||
&w_id,
|
||
&req.name,
|
||
ProtectionRules::from(&req.rules).bits(),
|
||
&req.bypass_groups,
|
||
&req.bypass_users,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.create_protection_rule",
|
||
ActionKind::Create,
|
||
&w_id,
|
||
Some(&req.name),
|
||
Some([("name", &req.name[..])].into()),
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
// Invalidate cache
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", req.name) },
|
||
None,
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Created protection rule '{}'", req.name))
|
||
}
|
||
|
||
/// Update an existing protection rule
|
||
async fn update_protection_rule(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path((w_id, rule_name)): Path<(String, String)>,
|
||
Json(req): Json<UpdateProtectionRuleRequest>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
// A rename moves the row's primary key, so it needs the same name checks a create does. The
|
||
// reserved rule can be neither end of one: the dev-workspace feature finds it by name, so
|
||
// renaming it away would strand the lock and renaming onto it would collide with the feature.
|
||
// Names are stored verbatim, as creation stores them, so this comparison is raw: a rule called
|
||
// " prod-lock " survives an edit that submits its current name back untouched, and a name that
|
||
// differs only in surrounding whitespace is a real rename rather than a silent no-op.
|
||
let new_name = req.name.as_deref().filter(|n| *n != rule_name);
|
||
if let Some(new_name) = new_name {
|
||
if new_name.trim().is_empty() {
|
||
return Err(Error::BadRequest(
|
||
"Protection rule name cannot be empty".to_string(),
|
||
));
|
||
}
|
||
if rule_name == DEV_WORKSPACE_LOCK_RULE_NAME {
|
||
return Err(Error::BadRequest(format!(
|
||
"'{}' cannot be renamed: the dev workspace feature locates it by name",
|
||
DEV_WORKSPACE_LOCK_RULE_NAME
|
||
)));
|
||
}
|
||
reject_reserved_rule_name(new_name)?;
|
||
}
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// Check if rule exists
|
||
let exists = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||
&w_id,
|
||
&rule_name
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
if !exists {
|
||
return Err(Error::NotFound(format!(
|
||
"Protection rule '{}' not found",
|
||
rule_name
|
||
)));
|
||
}
|
||
|
||
if let Some(new_name) = new_name {
|
||
let taken = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||
&w_id,
|
||
new_name
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if taken {
|
||
return Err(Error::BadRequest(format!(
|
||
"Protection rule with name '{}' already exists",
|
||
new_name
|
||
)));
|
||
}
|
||
}
|
||
|
||
let final_name = new_name.unwrap_or(&rule_name);
|
||
|
||
// Update the rule
|
||
sqlx::query!(
|
||
r#"
|
||
UPDATE workspace_protection_rule
|
||
SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4
|
||
WHERE workspace_id = $5 AND name = $6
|
||
"#,
|
||
final_name,
|
||
ProtectionRules::from(&req.rules).bits(),
|
||
&req.bypass_groups,
|
||
&req.bypass_users,
|
||
&w_id,
|
||
&rule_name
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let mut audit_args = std::collections::HashMap::from([("name", final_name)]);
|
||
if new_name.is_some() {
|
||
audit_args.insert("previous_name", &rule_name[..]);
|
||
}
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.update_protection_rule",
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(final_name),
|
||
Some(audit_args),
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
// Invalidate cache
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", final_name) },
|
||
None,
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Updated protection rule '{}'", final_name))
|
||
}
|
||
|
||
/// Delete a protection rule
|
||
async fn delete_protection_rule(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path((w_id, rule_name)): Path<(String, String)>,
|
||
) -> Result<String> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
reject_reserved_rule_name(&rule_name)?;
|
||
|
||
let mut tx = db.begin().await?;
|
||
|
||
// Delete the rule
|
||
let result = sqlx::query!(
|
||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||
&w_id,
|
||
&rule_name
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
if result.rows_affected() == 0 {
|
||
return Err(Error::NotFound(format!(
|
||
"Protection rule '{}' not found",
|
||
rule_name
|
||
)));
|
||
}
|
||
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
"workspaces.delete_protection_rule",
|
||
ActionKind::Delete,
|
||
&w_id,
|
||
Some(&rule_name),
|
||
Some([("name", &rule_name[..])].into()),
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
// Invalidate cache
|
||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||
|
||
handle_deployment_metadata(
|
||
&authed.email,
|
||
&authed.username,
|
||
&db,
|
||
&w_id,
|
||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
|
||
None,
|
||
false,
|
||
None,
|
||
)
|
||
.await?;
|
||
|
||
Ok(format!("Deleted protection rule '{}'", rule_name))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct WorkspaceComparison {
|
||
pub all_ahead_items_visible: bool,
|
||
pub all_behind_items_visible: bool,
|
||
pub skipped_comparison: bool,
|
||
pub diffs: Vec<WorkspaceDiffRow>,
|
||
pub summary: CompareSummary,
|
||
/// Items that exist in the diff but were dropped from `diffs` because they
|
||
/// are not visible to the caller (excluded from the partial deploy). Split
|
||
/// by direction: `hidden_ahead` lives in the fork, `hidden_behind` in the
|
||
/// parent. `by_kind`/`total` are always populated (aggregate, no names);
|
||
/// `items` (kind+path) is only filled for a caller who is admin of that side
|
||
/// — never leak the paths of items the ACL is hiding from a regular user.
|
||
pub hidden_ahead: HiddenItemsSummary,
|
||
pub hidden_behind: HiddenItemsSummary,
|
||
/// When the pair is outside the fork lineage, when its candidate set was last
|
||
/// seeded by an explicit full scan. Absent means the pair has never been
|
||
/// scanned, so an empty `diffs` says nothing about whether the two workspaces
|
||
/// agree. Always absent for a lineage pair, which the tally keeps current.
|
||
/// Omitted rather than null, as the schema declares.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub full_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
||
}
|
||
|
||
#[derive(Serialize, Default)]
|
||
pub struct HiddenItemsSummary {
|
||
pub total: usize,
|
||
pub by_kind: std::collections::BTreeMap<String, usize>,
|
||
pub items: Vec<HiddenItem>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct HiddenItem {
|
||
pub kind: String,
|
||
pub path: String,
|
||
}
|
||
|
||
#[derive(Serialize, Default)]
|
||
pub struct CompareSummary {
|
||
pub total_diffs: usize,
|
||
pub total_ahead: usize,
|
||
pub total_behind: usize,
|
||
pub scripts_changed: usize,
|
||
pub flows_changed: usize,
|
||
pub apps_changed: usize,
|
||
pub resources_changed: usize,
|
||
pub variables_changed: usize,
|
||
pub resource_types_changed: usize,
|
||
pub folders_changed: usize,
|
||
pub schedules_changed: usize,
|
||
pub triggers_changed: usize,
|
||
pub datatable_migrations_changed: usize,
|
||
pub conflicts: usize, // Items that are both ahead and behind
|
||
}
|
||
|
||
async fn reset_workspace_diffs(
|
||
authed: ApiAuthed,
|
||
Path((w_id, target_workspace_id)): Path<(String, String)>,
|
||
Extension(db): Extension<DB>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<()> {
|
||
// Needed to compute the has_changes: Option<bool>. Otherwise it will be None, and the query will not hit the items
|
||
let _ = compare_workspaces(
|
||
authed,
|
||
Path((w_id.clone(), target_workspace_id.clone())),
|
||
Extension(db.clone()),
|
||
Extension(user_db),
|
||
)
|
||
.await?;
|
||
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff WHERE has_changes = false AND (
|
||
(source_workspace_id = $1 AND fork_workspace_id = $2)
|
||
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
|
||
)",
|
||
target_workspace_id,
|
||
w_id,
|
||
)
|
||
.execute(&db)
|
||
.await?;
|
||
|
||
Ok(Json(()))
|
||
}
|
||
|
||
#[derive(Serialize, Debug, Clone, Default)]
|
||
pub struct WorkspaceDiffRow {
|
||
kind: String,
|
||
path: String,
|
||
ahead: i32,
|
||
behind: i32,
|
||
has_changes: Option<bool>,
|
||
exists_in_source: Option<bool>,
|
||
exists_in_fork: Option<bool>,
|
||
/// The last deploy event claimed on each side, per
|
||
/// `windmill_common::deploy_origin`. Omitted rather than null, as the schema
|
||
/// declares; absent means no evidence, which never justifies propagating a
|
||
/// removal.
|
||
///
|
||
/// Only the fork half is consumed today, by the merge direction. The update
|
||
/// direction has the mirror shape (a parent-side removal it cannot attribute)
|
||
/// but writes to the fork rather than to prod, and gating it on evidence would
|
||
/// strand a row a legacy tally left without any — so the source half is
|
||
/// recorded and surfaced, unread, rather than left as a gap to backfill.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
fork_last_event_kind: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
fork_last_event_origin: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
source_last_event_kind: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
source_last_event_origin: Option<String>,
|
||
}
|
||
|
||
async fn compare_workspaces(
|
||
authed: ApiAuthed,
|
||
Path((source_workspace_id, fork_workspace_id)): Path<(String, String)>,
|
||
Extension(db): Extension<DB>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
) -> JsonResult<WorkspaceComparison> {
|
||
// require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
// Only the lineage pair (source is the fork's parent) is kept current by the
|
||
// deploy tally. Any other pair is an explicit arbitrary-target comparison,
|
||
// which behaves differently on three counts, all handled below: it needs an
|
||
// explicit full scan to have a candidate set at all, it re-evaluates every
|
||
// candidate instead of trusting the cache, and it demands admin on both sides.
|
||
let is_lineage_pair = sqlx::query_scalar!(
|
||
"SELECT parent_workspace_id = $2 FROM workspace WHERE id = $1",
|
||
fork_workspace_id,
|
||
source_workspace_id,
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
.flatten()
|
||
.unwrap_or(false);
|
||
|
||
// Loaded eagerly only for the arbitrary pair, whose authorization needs it; the
|
||
// lineage path keeps it out of its early returns and loads it below.
|
||
let mut fork_authed: Option<ApiAuthed> = None;
|
||
|
||
let full_scan_at = if is_lineage_pair {
|
||
None
|
||
} else {
|
||
// An arbitrary pair exposes the two workspaces to each other, so require the
|
||
// caller to be an admin of both (superadmin folds into `is_admin` on either
|
||
// side). Without this, being an admin of one workspace would be enough to
|
||
// learn how many items of each kind differ in a workspace one merely belongs to.
|
||
let fa = load_workspace_authed(&db, &authed, &fork_workspace_id).await?;
|
||
if !(authed.is_admin && fa.is_admin) {
|
||
return Err(Error::BadRequest(format!(
|
||
"Comparing {fork_workspace_id} with {source_workspace_id}, which is not its parent workspace, requires being an admin of both workspaces"
|
||
)));
|
||
}
|
||
fork_authed = Some(fa);
|
||
sqlx::query_scalar!(
|
||
"SELECT scanned_at FROM workspace_diff_full_scan
|
||
WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
|
||
source_workspace_id,
|
||
fork_workspace_id,
|
||
)
|
||
.fetch_optional(&db)
|
||
.await?
|
||
};
|
||
|
||
// `skip_workspace_diff_tally` marks workspaces that predate the tally, whose
|
||
// lineage diff can therefore never be reconstructed from it. It says nothing
|
||
// about a pair whose candidate set was just enumerated in full.
|
||
let skipped_comparison: bool = if full_scan_at.is_some() {
|
||
false
|
||
} else {
|
||
sqlx::query_scalar(
|
||
"SELECT EXISTS(
|
||
SELECT 1 FROM skip_workspace_diff_tally
|
||
WHERE workspace_id = $1
|
||
)",
|
||
)
|
||
.bind(&fork_workspace_id)
|
||
.fetch_one(&db)
|
||
.await?
|
||
};
|
||
|
||
if skipped_comparison || (!is_lineage_pair && full_scan_at.is_none()) {
|
||
return Ok(Json(WorkspaceComparison {
|
||
all_ahead_items_visible: true,
|
||
all_behind_items_visible: true,
|
||
skipped_comparison,
|
||
diffs: vec![],
|
||
summary: Default::default(),
|
||
hidden_ahead: Default::default(),
|
||
hidden_behind: Default::default(),
|
||
full_scan_at,
|
||
}));
|
||
}
|
||
|
||
// Honor ws_specific at read time: a workspace-specific resource/variable keeps its own value per
|
||
// environment, so it must never appear in the normal diff (the per-item compare suppresses it,
|
||
// but a cached `has_changes=true` row is trusted without re-running that compare, so filter those
|
||
// here too). Seeding the initial copy onto a side that lacks it is a separate explicit action
|
||
// (the "Create in <other>" button on the Workspace-specific list), not part of the diff. The row
|
||
// is left intact, so unpinning resurfaces it without a re-tally.
|
||
let diff_items = sqlx::query_as!(
|
||
WorkspaceDiffRow,
|
||
"SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork,
|
||
fork_last_event_kind, fork_last_event_origin, source_last_event_kind, source_last_event_origin
|
||
FROM workspace_diff
|
||
WHERE source_workspace_id = $1 AND fork_workspace_id = $2
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM ws_specific ws
|
||
WHERE ws.path = workspace_diff.path
|
||
AND ws.item_kind = workspace_diff.kind
|
||
AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id)
|
||
)",
|
||
source_workspace_id,
|
||
fork_workspace_id,
|
||
)
|
||
.fetch_all(&db)
|
||
.await?;
|
||
|
||
// A cached `has_changes = true` row is trusted without re-running the
|
||
// per-kind comparison, but that verdict can go stale: an item archived or
|
||
// deleted after it was cached still carries `exists_in_*=true` here. The
|
||
// common offender is the old path after a rename — for lock-gen languages
|
||
// (Python/TS/…) the `has_changes=NULL` reset is deferred to the dependency
|
||
// job, so until that runs (or if it fails) the archived old path looks like
|
||
// a live ahead change. Treat archived as non-existent: re-validate such rows
|
||
// against the live tables and, if the item no longer exists on a side the
|
||
// cache claims, re-evaluate it below so it gets corrected or removed.
|
||
//
|
||
// Only scripts/flows can hit this (they have `archived`; other kinds reset
|
||
// synchronously on delete). Probe both sides in one batched query per kind
|
||
// (mirroring `query_visible_items`) rather than per row, to keep the hot
|
||
// compare path off an O(number of cached diffs) sequence of round trips.
|
||
//
|
||
// An arbitrary pair skips the probe entirely: nothing resets its cache, so it
|
||
// re-evaluates every candidate below and the staleness question never arises.
|
||
let trust_cache = is_lineage_pair;
|
||
let (live_source, live_fork) = {
|
||
let mut cached_source: HashMap<&str, Vec<&str>> = HashMap::new();
|
||
let mut cached_fork: HashMap<&str, Vec<&str>> = HashMap::new();
|
||
for item in &diff_items {
|
||
if trust_cache
|
||
&& item.has_changes == Some(true)
|
||
&& (item.kind == "script" || item.kind == "flow")
|
||
{
|
||
if item.exists_in_source.unwrap_or(false) {
|
||
cached_source
|
||
.entry(item.kind.as_str())
|
||
.or_default()
|
||
.push(item.path.as_str());
|
||
}
|
||
if item.exists_in_fork.unwrap_or(false) {
|
||
cached_fork
|
||
.entry(item.kind.as_str())
|
||
.or_default()
|
||
.push(item.path.as_str());
|
||
}
|
||
}
|
||
}
|
||
(
|
||
existing_runnables(&db, &source_workspace_id, &cached_source).await?,
|
||
existing_runnables(&db, &fork_workspace_id, &cached_fork).await?,
|
||
)
|
||
};
|
||
|
||
let mut confirmed_diffs = vec![];
|
||
for item in diff_items {
|
||
if let Some(has_changes) = item.has_changes.filter(|_| trust_cache) {
|
||
if !has_changes {
|
||
// Defensive: rows that compared equal are normally deleted, so
|
||
// this is rarely hit. Not a diff — skip.
|
||
continue;
|
||
}
|
||
// Stale only applies to script/flow (others aren't in the probed
|
||
// sets); a row whose claimed-existing side has no live version is
|
||
// stale and falls through to re-evaluation.
|
||
let key = (item.kind.clone(), item.path.clone());
|
||
let fork_stale = item.exists_in_fork.unwrap_or(false) && !live_fork.contains(&key);
|
||
let source_stale =
|
||
item.exists_in_source.unwrap_or(false) && !live_source.contains(&key);
|
||
let probed = item.kind == "script" || item.kind == "flow";
|
||
if !(probed && (fork_stale || source_stale)) {
|
||
// Cache is still valid (or not a probed kind) — trust it.
|
||
confirmed_diffs.push(item);
|
||
continue;
|
||
}
|
||
// Stale cache: fall through to re-evaluate (and correct/delete) below.
|
||
}
|
||
|
||
let item_comparison = match item.kind.as_str() {
|
||
"script" => Some(
|
||
compare_two_scripts(&db, &source_workspace_id, &fork_workspace_id, &item.path)
|
||
.await?,
|
||
),
|
||
"flow" => Some(
|
||
compare_two_flows(&db, &source_workspace_id, &fork_workspace_id, &item.path)
|
||
.await?,
|
||
),
|
||
"app" | "raw_app" => Some(
|
||
compare_two_apps(&db, &source_workspace_id, &fork_workspace_id, &item.path).await?,
|
||
),
|
||
"resource" => Some(
|
||
compare_two_resources(&db, &source_workspace_id, &fork_workspace_id, &item.path)
|
||
.await?,
|
||
),
|
||
"variable" => Some(
|
||
compare_two_variables(&db, &source_workspace_id, &fork_workspace_id, &item.path)
|
||
.await?,
|
||
),
|
||
"resource_type" => Some(
|
||
compare_two_resource_types(
|
||
&db,
|
||
&source_workspace_id,
|
||
&fork_workspace_id,
|
||
&item.path,
|
||
)
|
||
.await?,
|
||
),
|
||
"folder" => Some(
|
||
compare_two_folders(&db, &source_workspace_id, &fork_workspace_id, &item.path)
|
||
.await?,
|
||
),
|
||
"datatable_migration" => Some(
|
||
crate::datatable_migrations::compare_two_datatable_migration(
|
||
&db,
|
||
&source_workspace_id,
|
||
&fork_workspace_id,
|
||
&item.path,
|
||
)
|
||
.await?,
|
||
),
|
||
// Triggers and schedules are diffed against a hardcoded ignore list
|
||
// (mode/enabled/server_id/last_server_ping/edited_at/by/error/extra_perms/permissioned_as/email)
|
||
// so that fork-clones — which differ from the parent only in the runtime
|
||
// mode/enabled flag — don't show as diffs.
|
||
k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => Some(
|
||
compare_two_trigger_or_schedule(
|
||
&db,
|
||
item.kind.as_str(),
|
||
&source_workspace_id,
|
||
&fork_workspace_id,
|
||
&item.path,
|
||
)
|
||
.await?,
|
||
),
|
||
k => {
|
||
tracing::error!("Received unrecognized item kind `{k}` with path: `{}` while computing diff of {fork_workspace_id} and {source_workspace_id} workspaces. Skipping this item", item.path);
|
||
None
|
||
// Some(ItemComparison {
|
||
// has_changes: true,
|
||
// exists_in_source: true,
|
||
// exists_in_fork: true,
|
||
// })
|
||
}
|
||
};
|
||
|
||
if let Some(item_comparison) = item_comparison {
|
||
if item_comparison.has_changes {
|
||
sqlx::query!(
|
||
"UPDATE workspace_diff SET has_changes = true, exists_in_source = $5, exists_in_fork = $6
|
||
WHERE path = $3 AND kind = $4 AND (
|
||
(source_workspace_id = $1 AND fork_workspace_id = $2)
|
||
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
|
||
)",
|
||
source_workspace_id,
|
||
fork_workspace_id,
|
||
item.path,
|
||
item.kind,
|
||
item_comparison.exists_in_source,
|
||
item_comparison.exists_in_fork,
|
||
)
|
||
.execute(&db)
|
||
.await?;
|
||
confirmed_diffs.push(WorkspaceDiffRow {
|
||
has_changes: Some(item_comparison.has_changes),
|
||
exists_in_source: Some(item_comparison.exists_in_source),
|
||
exists_in_fork: Some(item_comparison.exists_in_fork),
|
||
..item
|
||
});
|
||
} else {
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff WHERE path = $3 AND kind = $4 AND (
|
||
(source_workspace_id = $1 AND fork_workspace_id = $2)
|
||
OR (source_workspace_id = $2 AND fork_workspace_id =$1)
|
||
)",
|
||
source_workspace_id,
|
||
fork_workspace_id,
|
||
item.path,
|
||
item.kind,
|
||
)
|
||
.execute(&db)
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The authed in `authed` is loaded for the source workspace (the one in the
|
||
// URL path). Its `folders`/`groups`/`is_admin` reflect membership in the
|
||
// source workspace only. Using it as the RLS context when querying the
|
||
// fork's tables would hide items the user can only see via fork-specific
|
||
// permissions (e.g. a folder the user owns in the fork but that does not
|
||
// exist in the source), causing the spurious
|
||
// "this fork has changes not visible to your user" warning. Build a
|
||
// matching authed for the fork so each side's visibility check uses the
|
||
// right RLS context.
|
||
let fork_authed = match fork_authed {
|
||
Some(fa) => fa,
|
||
None => load_workspace_authed(&db, &authed, &fork_workspace_id).await?,
|
||
};
|
||
let visible_diffs = filter_visible_diffs(
|
||
&confirmed_diffs,
|
||
&source_workspace_id,
|
||
&fork_workspace_id,
|
||
&authed,
|
||
&fork_authed,
|
||
&user_db,
|
||
)
|
||
.await?;
|
||
|
||
let summary = CompareSummary {
|
||
total_diffs: visible_diffs.len(),
|
||
total_ahead: visible_diffs
|
||
.iter()
|
||
.map(|s| s.ahead)
|
||
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0)),
|
||
total_behind: visible_diffs
|
||
.iter()
|
||
.map(|s| s.behind)
|
||
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0)),
|
||
scripts_changed: visible_diffs.iter().filter(|s| s.kind == "script").count(),
|
||
flows_changed: visible_diffs.iter().filter(|s| s.kind == "flow").count(),
|
||
apps_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind == "app" || s.kind == "raw_app")
|
||
.count(),
|
||
resources_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind == "resource")
|
||
.count(),
|
||
variables_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind == "variable")
|
||
.count(),
|
||
resource_types_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind == "resource_type")
|
||
.count(),
|
||
folders_changed: visible_diffs.iter().filter(|s| s.kind == "folder").count(),
|
||
schedules_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind == "schedule")
|
||
.count(),
|
||
triggers_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind.ends_with("_trigger"))
|
||
.count(),
|
||
datatable_migrations_changed: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.kind == "datatable_migration")
|
||
.count(),
|
||
conflicts: visible_diffs
|
||
.iter()
|
||
.filter(|s| s.ahead > 0 && s.behind > 0)
|
||
.count(),
|
||
};
|
||
|
||
// Each direction accounts for what it carries (see the frontend's
|
||
// `diffActionableInDirection`): a lineage merge leaves out a row the fork lacks
|
||
// unless the fork's own last event says it deleted or renamed it away, while the
|
||
// update takes it whatever the counters say — and since it can carry
|
||
// `behind = 0`, that side counts rows rather than sums.
|
||
let source_only = |d: &WorkspaceDiffRow| {
|
||
d.exists_in_source.unwrap_or(false) && !d.exists_in_fork.unwrap_or(false)
|
||
};
|
||
// Through the enums rather than their wire values: renaming one otherwise
|
||
// compiles clean on both sides and silently makes this always false.
|
||
let fork_removed_it = |d: &WorkspaceDiffRow| {
|
||
use windmill_common::deploy_origin::{DeployEventKind, DeployOrigin};
|
||
d.fork_last_event_origin.as_deref() == Some(DeployOrigin::Authored.as_str())
|
||
&& d.fork_last_event_kind.as_deref().is_some_and(|k| {
|
||
k == DeployEventKind::Delete.as_str() || k == DeployEventKind::RenameFrom.as_str()
|
||
})
|
||
};
|
||
let merge_carries =
|
||
|d: &WorkspaceDiffRow| !is_lineage_pair || !source_only(d) || fork_removed_it(d);
|
||
let ahead_sum = |rows: &[WorkspaceDiffRow]| {
|
||
rows.iter()
|
||
.filter(|d| merge_carries(d))
|
||
.map(|s| s.ahead)
|
||
.fold(0i64, |acc, s| acc + i64::from(s))
|
||
};
|
||
let all_ahead_items_visible = ahead_sum(&visible_diffs) == ahead_sum(&confirmed_diffs);
|
||
let all_behind_items_visible = summary.total_behind
|
||
== confirmed_diffs
|
||
.iter()
|
||
.map(|s| s.behind)
|
||
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0))
|
||
&& visible_diffs.iter().filter(|d| source_only(d)).count()
|
||
== confirmed_diffs.iter().filter(|d| source_only(d)).count();
|
||
|
||
// Blast-radius guard for the "changes not visible to your user" warning
|
||
// (which hides the deploy button). The flag is a pure visibility guarantee —
|
||
// the deploy re-authorizes each item against the target workspace's
|
||
// create/update endpoints — so it is safe to force true for a caller who sees
|
||
// every item on BOTH sides, for whom any diff the filter dropped is provably a
|
||
// stale/phantom row, never a permission gap.
|
||
//
|
||
// It must be BOTH sides, not per-side: `filter_visible_diffs` keeps a modified
|
||
// or conflict row (one that exists in the source AND the fork) only when the
|
||
// caller can see it on both sides, so an ahead/conflict diff can be dropped for
|
||
// a source-side visibility gap even when the caller is a fork admin. Gating the
|
||
// ahead flag on fork-admin alone would then wrongly report "all ahead visible"
|
||
// and let the UI deploy from an incomplete comparison. So require admin of the
|
||
// source AND the fork (superadmin satisfies both), which guarantees full
|
||
// visibility of every item on every side. `fork_authed.is_admin` already folds
|
||
// in superadmin; `authed.is_admin` (source side) does not, so OR it in.
|
||
let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
|
||
let sees_all_items = is_super_admin || (authed.is_admin && fork_authed.is_admin);
|
||
let all_ahead_items_visible = all_ahead_items_visible || sees_all_items;
|
||
let all_behind_items_visible = all_behind_items_visible || sees_all_items;
|
||
|
||
// Items dropped by the visibility filter (in confirmed_diffs but not in the
|
||
// returned visible_diffs). Surface what the partial deploy excludes: aggregate
|
||
// counts by kind for everyone, but kind+path only to a caller who `sees_all_items`
|
||
// (superadmin, or admin of the source AND the fork). For them a dropped item is
|
||
// provably a phantom/stale row, not an ACL-hidden secret, so no path leaks —
|
||
// fork-admin alone is not enough (a fork-deleted ahead item lives only in the
|
||
// parent, whose path a non-parent-admin must not see).
|
||
let visible_keys: HashSet<(&str, &str)> = visible_diffs
|
||
.iter()
|
||
.map(|d| (d.kind.as_str(), d.path.as_str()))
|
||
.collect();
|
||
let mut hidden_ahead = HiddenItemsSummary::default();
|
||
let mut hidden_behind = HiddenItemsSummary::default();
|
||
for d in &confirmed_diffs {
|
||
if visible_keys.contains(&(d.kind.as_str(), d.path.as_str())) {
|
||
continue;
|
||
}
|
||
// Both sides mirror the flags above: a row is only withheld from a direction
|
||
// that would have carried it.
|
||
if d.ahead > 0 && merge_carries(d) {
|
||
hidden_ahead.total += 1;
|
||
*hidden_ahead.by_kind.entry(d.kind.clone()).or_default() += 1;
|
||
if sees_all_items {
|
||
hidden_ahead
|
||
.items
|
||
.push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() });
|
||
}
|
||
}
|
||
if d.behind > 0 || source_only(d) {
|
||
hidden_behind.total += 1;
|
||
*hidden_behind.by_kind.entry(d.kind.clone()).or_default() += 1;
|
||
if sees_all_items {
|
||
hidden_behind
|
||
.items
|
||
.push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() });
|
||
}
|
||
}
|
||
}
|
||
|
||
return Ok(Json(WorkspaceComparison {
|
||
all_ahead_items_visible,
|
||
all_behind_items_visible,
|
||
skipped_comparison: false,
|
||
diffs: visible_diffs,
|
||
summary,
|
||
hidden_ahead,
|
||
hidden_behind,
|
||
full_scan_at,
|
||
}));
|
||
}
|
||
|
||
/// Every `(kind, path)` deployable item currently in a workspace, keyed the same
|
||
/// way `workspace_diff` rows are. Mirrors the kind → table mapping of
|
||
/// `query_visible_items`, minus its path filter: this enumerates the candidate set
|
||
/// for a pair no tally maintains.
|
||
///
|
||
/// Runs on `&db` (no RLS). Enumerating everything is what makes the diff complete;
|
||
/// per-item authorization stays in `filter_visible_diffs`, and reaching this at all
|
||
/// requires admin of both workspaces.
|
||
async fn list_all_item_keys(db: &DB, workspace_id: &str) -> Result<Vec<(String, String)>> {
|
||
let mut keys: Vec<(String, String)> = vec![];
|
||
|
||
fn push(keys: &mut Vec<(String, String)>, kind: &str, paths: Vec<String>) {
|
||
keys.extend(paths.into_iter().map(|p| (kind.to_string(), p)));
|
||
}
|
||
|
||
push(
|
||
&mut keys,
|
||
"script",
|
||
sqlx::query_scalar!(
|
||
"SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
);
|
||
push(
|
||
&mut keys,
|
||
"flow",
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM flow WHERE workspace_id = $1 AND archived = false",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
);
|
||
// Raw apps live in the `app` table too — what sets them apart is `raw_app` on
|
||
// their latest version. The kind decides which editor the merge UI links to and
|
||
// which deploy path an item takes, so read it from the version, as the delete
|
||
// handler does when it picks the DeployedObject variant.
|
||
for row in sqlx::query!(
|
||
"SELECT app.path, app_version.raw_app FROM app
|
||
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||
WHERE app.workspace_id = $1",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?
|
||
{
|
||
keys.push((
|
||
if row.raw_app { "raw_app" } else { "app" }.to_string(),
|
||
row.path,
|
||
));
|
||
}
|
||
push(
|
||
&mut keys,
|
||
"resource",
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM resource WHERE workspace_id = $1",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
);
|
||
push(
|
||
&mut keys,
|
||
"variable",
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM variable WHERE workspace_id = $1",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
);
|
||
push(
|
||
&mut keys,
|
||
"resource_type",
|
||
sqlx::query_scalar!(
|
||
"SELECT name FROM resource_type WHERE workspace_id = $1",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
);
|
||
push(
|
||
&mut keys,
|
||
"folder",
|
||
sqlx::query_scalar!(
|
||
"SELECT 'f/' || name FROM folder WHERE workspace_id = $1",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?
|
||
.into_iter()
|
||
.flatten()
|
||
.collect(),
|
||
);
|
||
push(
|
||
&mut keys,
|
||
"datatable_migration",
|
||
sqlx::query_scalar!(
|
||
"SELECT datatable || '/' || timestamp || '_' || name FROM datatable_migrations
|
||
WHERE workspace_id = $1",
|
||
workspace_id
|
||
)
|
||
.fetch_all(db)
|
||
.await?
|
||
.into_iter()
|
||
.flatten()
|
||
.collect(),
|
||
);
|
||
for table in TRIGGER_OR_SCHEDULE_TABLES {
|
||
// SAFETY: `table` comes from the hardcoded TRIGGER_OR_SCHEDULE_TABLES
|
||
// allowlist, not user input.
|
||
let sql = format!("SELECT path FROM {table} WHERE workspace_id = $1");
|
||
push(
|
||
&mut keys,
|
||
table,
|
||
sqlx::query_scalar(&sql)
|
||
.bind(workspace_id)
|
||
.fetch_all(db)
|
||
.await?,
|
||
);
|
||
}
|
||
|
||
Ok(keys)
|
||
}
|
||
|
||
/// Whether the two workspaces are directly linked by the fork lineage, in either
|
||
/// direction — the only relationship the deploy tally maintains.
|
||
async fn is_lineage_pair<'e>(
|
||
executor: impl sqlx::PgExecutor<'e>,
|
||
a: &str,
|
||
b: &str,
|
||
) -> Result<bool> {
|
||
Ok(sqlx::query_scalar!(
|
||
"SELECT EXISTS(
|
||
SELECT 1 FROM workspace
|
||
WHERE (id = $1 AND parent_workspace_id = $2) OR (id = $2 AND parent_workspace_id = $1)
|
||
)",
|
||
a,
|
||
b,
|
||
)
|
||
.fetch_one(executor)
|
||
.await?
|
||
.unwrap_or(false))
|
||
}
|
||
|
||
/// Serialize the operations that rewrite a workspace pair's `workspace_diff` rows:
|
||
/// a full scan, and the attach that turns the pair into a lineage pair and clears
|
||
/// those rows. Without a common lock a scan can seed one-way candidates *after* an
|
||
/// attach has cleaned them up, leaving the tally reading rows that guessed a
|
||
/// direction. Order-independent, so both directions of a pair take the same lock.
|
||
async fn lock_workspace_pair(tx: &mut Transaction<'_, Postgres>, a: &str, b: &str) -> Result<()> {
|
||
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||
sqlx::query!(
|
||
"SELECT pg_advisory_xact_lock(hashtext('workspace_diff_pair:' || $1 || '/' || $2))",
|
||
lo,
|
||
hi,
|
||
)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct FullDiffScan {
|
||
/// Candidate items the scan will compare — the union of both workspaces'
|
||
/// items. The comparison itself prunes the ones that turn out to be equal.
|
||
pub candidates: usize,
|
||
pub scanned_at: chrono::DateTime<chrono::Utc>,
|
||
}
|
||
|
||
/// Seed the `workspace_diff` candidate set for a pair the fork tally does not
|
||
/// maintain, so the merge UI can target a workspace outside the lineage.
|
||
///
|
||
/// The tally only ever records a fork against its parent, so an arbitrary pair
|
||
/// starts with no rows and would compare as "identical". This enumerates every
|
||
/// item on both sides as an undecided candidate (`has_changes = NULL`) and records
|
||
/// the scan; the following `compare_workspaces` is what actually compares them,
|
||
/// keeps the differing rows and deletes the rest. That first comparison is the
|
||
/// expensive step — it costs a per-kind query pair per candidate — which is why
|
||
/// scanning is an explicit action and its result is left in `workspace_diff` for
|
||
/// subsequent loads to reuse.
|
||
///
|
||
/// Direction is one-way: candidates are seeded `ahead = 1`, i.e. changes to push
|
||
/// from `w_id` into `target_workspace_id`. A cold scan has no deploy history to
|
||
/// tell which side moved, so there is nothing to say in the other direction.
|
||
async fn seed_full_diff_scan(
|
||
authed: ApiAuthed,
|
||
Path((w_id, target_workspace_id)): Path<(String, String)>,
|
||
Extension(db): Extension<DB>,
|
||
) -> JsonResult<FullDiffScan> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
if w_id == target_workspace_id {
|
||
return Err(Error::BadRequest(
|
||
"Cannot compare a workspace with itself".to_string(),
|
||
));
|
||
}
|
||
|
||
let target_exists = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM workspace WHERE id = $1 AND deleted = false)",
|
||
target_workspace_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(false);
|
||
if !target_exists {
|
||
return Err(Error::NotFound(format!(
|
||
"Workspace {target_workspace_id} does not exist"
|
||
)));
|
||
}
|
||
|
||
let target_authed = load_workspace_authed(&db, &authed, &target_workspace_id).await?;
|
||
if !target_authed.is_admin {
|
||
return Err(Error::BadRequest(format!(
|
||
"Computing a diff against {target_workspace_id} requires being an admin of it"
|
||
)));
|
||
}
|
||
|
||
// The lineage pair has its own tally, whose `ahead`/`behind` counters say which
|
||
// side a change came from. Seeding one-way candidates over it would overwrite
|
||
// that direction with a guess, so leave the lineage comparison to the tally.
|
||
// Checked again under the pair lock below, since the answer can change under us.
|
||
if is_lineage_pair(&db, &w_id, &target_workspace_id).await? {
|
||
return Err(Error::BadRequest(format!(
|
||
"{w_id} and {target_workspace_id} are already linked by the fork lineage — their diff is tracked continuously and needs no full scan"
|
||
)));
|
||
}
|
||
|
||
let mut candidates = list_all_item_keys(&db, &w_id).await?;
|
||
let target_items = list_all_item_keys(&db, &target_workspace_id).await?;
|
||
|
||
// Two kinds identify an item by less than their `(kind, path)` key, so the same
|
||
// logical item can key differently on each side. Their comparison matches on the
|
||
// weaker identity, so seeding both keys would list — and deploy — one item twice.
|
||
// Both collapse onto this workspace's key, the one a deploy writes.
|
||
//
|
||
// A data table migration keeps its identity across a rename, so the same
|
||
// `(datatable, timestamp)` can carry a different name, hence a different path.
|
||
let own_migrations: HashSet<(String, i64)> = candidates
|
||
.iter()
|
||
.filter(|(kind, _)| kind == "datatable_migration")
|
||
.filter_map(|(_, path)| {
|
||
crate::datatable_migrations::parse_datatable_migration_diff_path(path)
|
||
})
|
||
.collect();
|
||
// An app and a raw app share the `app` table and one path per workspace —
|
||
// converting one into the other keeps the path and changes only the kind.
|
||
let own_app_paths: HashSet<String> = candidates
|
||
.iter()
|
||
.filter(|(kind, _)| kind == "app" || kind == "raw_app")
|
||
.map(|(_, path)| path.clone())
|
||
.collect();
|
||
candidates.extend(target_items.into_iter().filter(|(kind, path)| {
|
||
match kind.as_str() {
|
||
"datatable_migration" => {
|
||
!crate::datatable_migrations::parse_datatable_migration_diff_path(path)
|
||
.is_some_and(|key| own_migrations.contains(&key))
|
||
}
|
||
"app" | "raw_app" => !own_app_paths.contains(path),
|
||
_ => true,
|
||
}
|
||
}));
|
||
candidates.sort();
|
||
candidates.dedup();
|
||
|
||
let kinds: Vec<String> = candidates.iter().map(|(k, _)| k.clone()).collect();
|
||
let paths: Vec<String> = candidates.iter().map(|(_, p)| p.clone()).collect();
|
||
|
||
let mut tx = db.begin().await?;
|
||
lock_workspace_pair(&mut tx, &w_id, &target_workspace_id).await?;
|
||
// Re-checked under the lock: the pair may have been linked since the check above,
|
||
// and `attach_dev_workspace` clears the arbitrary rows as it links. Seeding after
|
||
// that cleanup would leave one-way `ahead` rows on a pair the compare now treats
|
||
// as lineage, listing the target's own items as default-selected deletions.
|
||
if is_lineage_pair(&mut *tx, &w_id, &target_workspace_id).await? {
|
||
return Err(Error::BadRequest(format!(
|
||
"{w_id} and {target_workspace_id} were linked by the fork lineage while the scan ran — their diff is tracked continuously and needs no full scan"
|
||
)));
|
||
}
|
||
// The scan replaces this pair's candidate set rather than adding to it. Merging
|
||
// would keep a key the new scan no longer produces, and the comparison
|
||
// re-evaluates every row of a non-lineage pair, so a renamed migration or an
|
||
// app converted to a raw app would come back beside its new key — the very
|
||
// duplicate the collapses above remove, one Recompute later. Nothing of value is
|
||
// dropped: the verdicts are recomputed anyway, and rows left by a lineage link
|
||
// since dissolved carry a `behind` that would hide the item from the one-way list.
|
||
sqlx::query!(
|
||
"DELETE FROM workspace_diff WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
|
||
target_workspace_id,
|
||
w_id,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
sqlx::query!(
|
||
"INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
||
SELECT $1, $2, path, kind, 1, 0, NULL
|
||
FROM unnest($3::varchar[], $4::varchar[]) AS t(kind, path)",
|
||
target_workspace_id,
|
||
w_id,
|
||
&kinds,
|
||
&paths,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
|
||
let scanned_at = sqlx::query_scalar!(
|
||
"INSERT INTO workspace_diff_full_scan (source_workspace_id, fork_workspace_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (source_workspace_id, fork_workspace_id) DO UPDATE SET scanned_at = now()
|
||
RETURNING scanned_at",
|
||
target_workspace_id,
|
||
w_id,
|
||
)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
|
||
Ok(Json(FullDiffScan {
|
||
candidates: candidates.len(),
|
||
scanned_at,
|
||
}))
|
||
}
|
||
|
||
/// Build an `ApiAuthed` for the same user but scoped to a different workspace.
|
||
///
|
||
/// Reloads `is_admin`, `groups`, and `folders` from the target workspace's
|
||
/// `usr` / `group_` / `folder` tables (keyed by the caller's email) so the
|
||
/// returned authed can be used as the RLS context for queries against that
|
||
/// workspace. `is_admin` is OR'd with the user's superadmin status so cross-
|
||
/// workspace superadmins keep their RLS bypass.
|
||
///
|
||
/// If the user is not a member of `workspace_id`, returns an authed with no
|
||
/// folders/groups/operator/admin (except for superadmins, who stay admin) —
|
||
/// i.e. they will only see what RLS explicitly allows for unknown users.
|
||
async fn load_workspace_authed(
|
||
db: &DB,
|
||
base_authed: &ApiAuthed,
|
||
workspace_id: &str,
|
||
) -> Result<ApiAuthed> {
|
||
let mut conn = db
|
||
.acquire()
|
||
.await
|
||
.map_err(|e| Error::internal_err(e.to_string()))?;
|
||
|
||
// Job-aware: this grants an admin claim in a workspace the caller may have no
|
||
// relationship with, and `job_id` is carried into the result — so a `WM_TOKEN`
|
||
// whose on-behalf identity is a superadmin would hold admin everywhere
|
||
// (GHSA-hfh4-cx4h-3fcr). It then falls through to its real membership below.
|
||
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?;
|
||
|
||
let user_row = sqlx::query!(
|
||
"SELECT username, is_admin, operator FROM usr
|
||
WHERE workspace_id = $1 AND email = $2 AND disabled = false",
|
||
workspace_id,
|
||
&base_authed.email
|
||
)
|
||
.fetch_optional(&mut *conn)
|
||
.await?;
|
||
|
||
let Some(user_row) = user_row else {
|
||
return Ok(ApiAuthed {
|
||
email: base_authed.email.clone(),
|
||
username: base_authed.username.clone(),
|
||
is_admin: is_super_admin,
|
||
is_operator: false,
|
||
groups: vec![],
|
||
folders: vec![],
|
||
scopes: base_authed.scopes.clone(),
|
||
username_override: base_authed.username_override.clone(),
|
||
username_override_is_token_label: base_authed.username_override_is_token_label,
|
||
is_session_token: base_authed.is_session_token,
|
||
token_prefix: base_authed.token_prefix.clone(),
|
||
read_only: base_authed.read_only,
|
||
job_id: base_authed.job_id,
|
||
credential_expiry: base_authed.credential_expiry,
|
||
});
|
||
};
|
||
|
||
let groups = windmill_common::auth::get_groups_for_user(
|
||
workspace_id,
|
||
&user_row.username,
|
||
&base_authed.email,
|
||
&mut *conn,
|
||
)
|
||
.await?;
|
||
let folders = windmill_common::auth::get_folders_for_user(
|
||
workspace_id,
|
||
&user_row.username,
|
||
&groups,
|
||
&mut *conn,
|
||
)
|
||
.await?;
|
||
|
||
Ok(ApiAuthed {
|
||
email: base_authed.email.clone(),
|
||
username: user_row.username,
|
||
is_admin: is_super_admin || user_row.is_admin,
|
||
is_operator: user_row.operator,
|
||
groups,
|
||
folders,
|
||
scopes: base_authed.scopes.clone(),
|
||
username_override: base_authed.username_override.clone(),
|
||
username_override_is_token_label: base_authed.username_override_is_token_label,
|
||
is_session_token: base_authed.is_session_token,
|
||
token_prefix: base_authed.token_prefix.clone(),
|
||
read_only: base_authed.read_only,
|
||
job_id: base_authed.job_id,
|
||
credential_expiry: base_authed.credential_expiry,
|
||
})
|
||
}
|
||
|
||
async fn filter_visible_diffs(
|
||
confirmed_diffs: &[WorkspaceDiffRow],
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
source_authed: &ApiAuthed,
|
||
fork_authed: &ApiAuthed,
|
||
user_db: &UserDB,
|
||
) -> Result<Vec<WorkspaceDiffRow>> {
|
||
// Step 1: Group paths by (workspace, kind)
|
||
let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new();
|
||
let mut fork_items: HashMap<&str, Vec<&str>> = HashMap::new();
|
||
|
||
for diff in confirmed_diffs {
|
||
if diff.exists_in_source.unwrap_or(false) {
|
||
source_items.entry(&diff.kind).or_default().push(&diff.path);
|
||
}
|
||
if diff.exists_in_fork.unwrap_or(false) {
|
||
fork_items.entry(&diff.kind).or_default().push(&diff.path);
|
||
}
|
||
}
|
||
|
||
// Step 2: Batch query for each (workspace, kind) combination, each in its
|
||
// own transaction so RLS uses the right authed for each side. The fork's
|
||
// authed picks up fork-only folders/groups; without this split the fork
|
||
// queries would run with the source workspace's permissions and miss any
|
||
// item the user can only reach through fork-specific permissions.
|
||
let source_visible = {
|
||
let mut tx = user_db.clone().begin(source_authed).await?;
|
||
let visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?;
|
||
tx.commit().await?;
|
||
visible
|
||
};
|
||
let fork_visible = {
|
||
let mut tx = user_db.clone().begin(fork_authed).await?;
|
||
let visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?;
|
||
tx.commit().await?;
|
||
visible
|
||
};
|
||
|
||
// Step 3: Filter diffs based on visibility
|
||
let visible_diffs: Vec<WorkspaceDiffRow> = confirmed_diffs
|
||
.iter()
|
||
.filter(|diff| {
|
||
let v = (diff.kind.to_string(), diff.path.to_string());
|
||
let source_ok = !diff.exists_in_source.unwrap_or(false) || source_visible.contains(&v);
|
||
let fork_ok = !diff.exists_in_fork.unwrap_or(false) || fork_visible.contains(&v);
|
||
source_ok && fork_ok
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
|
||
Ok(visible_diffs)
|
||
}
|
||
|
||
async fn query_visible_items<'c>(
|
||
tx: &mut Transaction<'c, Postgres>,
|
||
workspace_id: &str,
|
||
items_by_kind: &HashMap<&str, Vec<&str>>,
|
||
) -> Result<HashSet<(String, String)>> {
|
||
let mut visible = HashSet::new();
|
||
|
||
for (kind, paths) in items_by_kind {
|
||
let paths_vec: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
|
||
|
||
let results = match *kind {
|
||
"script" => {
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM script
|
||
WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
"flow" => {
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM flow
|
||
WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
"app" | "raw_app" => {
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM app
|
||
WHERE workspace_id = $1 AND path = ANY($2)",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
"resource" => {
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM resource
|
||
WHERE workspace_id = $1 AND path = ANY($2)",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
"variable" => {
|
||
sqlx::query_scalar!(
|
||
"SELECT path FROM variable
|
||
WHERE workspace_id = $1 AND path = ANY($2)",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
"folder" => {
|
||
let a: Vec<String> = paths_vec
|
||
.iter()
|
||
.map(|p| p.strip_prefix("f/").unwrap_or(p.as_str()).to_string())
|
||
.collect();
|
||
sqlx::query_scalar!(
|
||
"SELECT name FROM folder
|
||
WHERE workspace_id = $1 AND name = ANY($2)",
|
||
workspace_id,
|
||
&a,
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
.into_iter()
|
||
.map(|p| format!("f/{p}"))
|
||
.collect()
|
||
}
|
||
"resource_type" => {
|
||
sqlx::query_scalar!(
|
||
"SELECT name FROM resource_type
|
||
WHERE workspace_id = $1 AND name = ANY($2)",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
"datatable_migration" => {
|
||
// Match by (datatable, timestamp), not the full path: a migration
|
||
// keeps its identity across a rename, so the candidate path's
|
||
// `name` segment can differ from the stored one. Parse each
|
||
// `<datatable>/<timestamp>_<name>` candidate, probe existence by
|
||
// (datatable, timestamp), and return the *original* candidate path
|
||
// so the visibility set stays keyed by the diff's path.
|
||
let parsed: Vec<(String, i64, String)> = paths_vec
|
||
.iter()
|
||
.filter_map(|p| {
|
||
let (dt, rest) = p.split_once('/')?;
|
||
let ts = rest.split_once('_')?.0.parse::<i64>().ok()?;
|
||
Some((dt.to_string(), ts, p.clone()))
|
||
})
|
||
.collect();
|
||
if parsed.is_empty() {
|
||
vec![]
|
||
} else {
|
||
let dts: Vec<String> = parsed.iter().map(|(d, _, _)| d.clone()).collect();
|
||
let tss: Vec<i64> = parsed.iter().map(|(_, t, _)| *t).collect();
|
||
let existing: HashSet<(String, i64)> = sqlx::query!(
|
||
"SELECT datatable, timestamp FROM datatable_migrations \
|
||
WHERE workspace_id = $1 AND datatable = ANY($2) AND timestamp = ANY($3)",
|
||
workspace_id,
|
||
&dts,
|
||
&tss,
|
||
)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
.into_iter()
|
||
.map(|r| (r.datatable, r.timestamp))
|
||
.collect();
|
||
parsed
|
||
.into_iter()
|
||
.filter(|(d, t, _)| existing.contains(&(d.clone(), *t)))
|
||
.map(|(_, _, p)| p)
|
||
.collect()
|
||
}
|
||
}
|
||
k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => {
|
||
// SAFETY: `kind` comes from a hardcoded allowlist
|
||
// TRIGGER_OR_SCHEDULE_TABLES, not user input.
|
||
let sql =
|
||
format!("SELECT path FROM {kind} WHERE workspace_id = $1 AND path = ANY($2)");
|
||
sqlx::query_scalar(&sql)
|
||
.bind(workspace_id)
|
||
.bind(&paths_vec)
|
||
.fetch_all(&mut **tx)
|
||
.await?
|
||
}
|
||
_ => vec![], // Unknown kind
|
||
};
|
||
|
||
for path in results {
|
||
visible.insert((kind.to_string(), path));
|
||
}
|
||
}
|
||
|
||
Ok(visible)
|
||
}
|
||
|
||
/// Batched existence probe used to detect stale `workspace_diff` cache rows.
|
||
///
|
||
/// Given candidate paths grouped by kind, returns the set of `(kind, path)`
|
||
/// that currently have a *deployable* (non-archived) version in the workspace,
|
||
/// mirroring the existence semantics of `compare_two_scripts` /
|
||
/// `compare_two_flows`. Only scripts and flows are probed — they're the only
|
||
/// kinds with `archived`, and the only ones whose diff-row reset can lag behind
|
||
/// the actual change (deferred dependency job for lock-gen languages); other
|
||
/// kinds reset synchronously on delete, so their cache is trusted (and they're
|
||
/// never passed in). One query per kind keeps the compare path off a per-row
|
||
/// sequence of round trips. Runs on `&db` (no RLS) — this is a pure existence
|
||
/// check; authorization stays in `filter_visible_diffs` / `query_visible_items`.
|
||
async fn existing_runnables(
|
||
db: &DB,
|
||
workspace_id: &str,
|
||
items_by_kind: &HashMap<&str, Vec<&str>>,
|
||
) -> Result<HashSet<(String, String)>> {
|
||
let mut existing = HashSet::new();
|
||
for (kind, paths) in items_by_kind {
|
||
let paths_vec: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
|
||
let found: Vec<String> = match *kind {
|
||
"script" => sqlx::query_scalar!(
|
||
"SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
"flow" => sqlx::query_scalar!(
|
||
"SELECT path FROM flow WHERE workspace_id = $1 AND path = ANY($2) AND archived = false",
|
||
workspace_id,
|
||
&paths_vec
|
||
)
|
||
.fetch_all(db)
|
||
.await?,
|
||
_ => vec![],
|
||
};
|
||
for path in found {
|
||
existing.insert((kind.to_string(), path));
|
||
}
|
||
}
|
||
Ok(existing)
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub(crate) struct ItemComparison {
|
||
pub(crate) has_changes: bool,
|
||
pub(crate) exists_in_source: bool,
|
||
pub(crate) exists_in_fork: bool,
|
||
}
|
||
|
||
async fn compare_two_scripts(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
path: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Get latest script from each workspace
|
||
let source_script = sqlx::query!(
|
||
"SELECT hash, created_at, content, summary, description, lock, schema
|
||
FROM script
|
||
WHERE workspace_id = $1 AND path = $2 AND archived = false
|
||
ORDER BY created_at DESC
|
||
LIMIT 1",
|
||
source_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_script = sqlx::query!(
|
||
"SELECT hash, created_at, content, summary, description, lock, schema
|
||
FROM script
|
||
WHERE workspace_id = $1 AND path = $2 AND archived = false
|
||
ORDER BY created_at DESC
|
||
LIMIT 1",
|
||
fork_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata differences
|
||
if let (Some(source), Some(target)) = (&source_script, &target_script) {
|
||
if source.content != target.content
|
||
|| source.summary != target.summary
|
||
|| source.description != target.description
|
||
|| source.lock != target.lock
|
||
|| source.schema != target.schema
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_script.is_some() || target_script.is_some() {
|
||
// The script exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_script.is_some(),
|
||
exists_in_fork: target_script.is_some(),
|
||
});
|
||
}
|
||
|
||
async fn compare_two_flows(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
path: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Get latest flow from each workspace
|
||
let source_flow = sqlx::query!(
|
||
"SELECT value, summary, description, schema
|
||
FROM flow
|
||
WHERE workspace_id = $1 AND path = $2 AND archived = false",
|
||
source_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_flow = sqlx::query!(
|
||
"SELECT value, summary, description, schema
|
||
FROM flow
|
||
WHERE workspace_id = $1 AND path = $2 AND archived = false",
|
||
fork_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata differences
|
||
if let (Some(source), Some(target)) = (&source_flow, &target_flow) {
|
||
if source.value != target.value
|
||
|| source.summary != target.summary
|
||
|| source.description != target.description
|
||
|| source.schema != target.schema
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_flow.is_some() || target_flow.is_some() {
|
||
// The flow exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_flow.is_some(),
|
||
exists_in_fork: target_flow.is_some(),
|
||
});
|
||
}
|
||
|
||
/// The policy minus the identity pair. Deploying cannot converge a difference there — the
|
||
/// target recomputes the identity from the deployer's own choice, which offers its current
|
||
/// value, the deployer, or a typed-in one, never the source's — so listing an app for it
|
||
/// alone leaves an entry no deploy can clear. `script` and `flow` compare no identity either.
|
||
fn policy_without_identity(policy: &serde_json::Value) -> serde_json::Value {
|
||
let mut policy = policy.clone();
|
||
if let Some(obj) = policy.as_object_mut() {
|
||
obj.remove("on_behalf_of");
|
||
obj.remove("on_behalf_of_email");
|
||
}
|
||
policy
|
||
}
|
||
|
||
async fn compare_two_apps(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
path: &str,
|
||
) -> Result<ItemComparison> {
|
||
// `raw_app` is compared alongside the content: converting an app into a raw app
|
||
// (or back) keeps the path and can keep every other field, and the merge UI links
|
||
// to a different editor and deploys down a different path for each, so a
|
||
// conversion is a change even when nothing else moved.
|
||
// Get app with its latest version data from source workspace
|
||
let source_app = sqlx::query!(
|
||
"SELECT app.summary, app.policy, app_version.value, app_version.raw_app
|
||
FROM app
|
||
JOIN app_version
|
||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||
WHERE app.workspace_id = $1 AND app.path = $2",
|
||
source_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_app = sqlx::query!(
|
||
"SELECT app.summary, app.policy, app_version.value, app_version.raw_app
|
||
FROM app
|
||
JOIN app_version
|
||
ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||
WHERE app.workspace_id = $1 AND app.path = $2",
|
||
fork_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata and content differences
|
||
if let (Some(source), Some(target)) = (&source_app, &target_app) {
|
||
if source.summary != target.summary
|
||
|| policy_without_identity(&source.policy) != policy_without_identity(&target.policy)
|
||
|| source.value != target.value
|
||
|| source.raw_app != target.raw_app
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_app.is_some() || target_app.is_some() {
|
||
// The app exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_app.is_some(),
|
||
exists_in_fork: target_app.is_some(),
|
||
});
|
||
}
|
||
|
||
async fn compare_two_resources(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
path: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Get resource from each workspace
|
||
let source_resource = sqlx::query!(
|
||
"SELECT value, description, resource_type
|
||
FROM resource
|
||
WHERE workspace_id = $1 AND path = $2",
|
||
source_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_resource = sqlx::query!(
|
||
"SELECT value, description, resource_type
|
||
FROM resource
|
||
WHERE workspace_id = $1 AND path = $2",
|
||
fork_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let source_ws_specific = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)",
|
||
source_workspace_id,
|
||
path
|
||
)
|
||
.fetch_one(db)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
let target_ws_specific = sqlx::query_scalar!(
|
||
"SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)",
|
||
fork_workspace_id,
|
||
path
|
||
)
|
||
.fetch_one(db)
|
||
.await?
|
||
.unwrap_or(false);
|
||
|
||
// A workspace-specific resource keeps its own value per environment, so it never appears in the
|
||
// diff (in either direction). Seeding the initial copy onto a side that lacks it is a separate
|
||
// explicit action ("Create in <other>"), not a diff entry.
|
||
if source_ws_specific || target_ws_specific {
|
||
return Ok(ItemComparison {
|
||
has_changes: false,
|
||
exists_in_source: source_resource.is_some(),
|
||
exists_in_fork: target_resource.is_some(),
|
||
});
|
||
}
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata differences
|
||
if let (Some(source), Some(target)) = (&source_resource, &target_resource) {
|
||
if source.value != target.value
|
||
|| source.description != target.description
|
||
|| source.resource_type != target.resource_type
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_resource.is_some() || target_resource.is_some() {
|
||
// The resource exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_resource.is_some(),
|
||
exists_in_fork: target_resource.is_some(),
|
||
});
|
||
}
|
||
|
||
async fn compare_two_variables(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
path: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Combine the four EXISTS checks (ws_specific × {source, fork}, variable
|
||
// × {source, fork}) into a single round-trip; this runs per variable
|
||
// during a workspace diff so the savings add up.
|
||
let presence = sqlx::query!(
|
||
r#"SELECT
|
||
EXISTS(SELECT 1 FROM ws_specific
|
||
WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $3) AS "src_ws!",
|
||
EXISTS(SELECT 1 FROM ws_specific
|
||
WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3) AS "tgt_ws!",
|
||
EXISTS(SELECT 1 FROM variable
|
||
WHERE workspace_id = $1 AND path = $3) AS "src_var!",
|
||
EXISTS(SELECT 1 FROM variable
|
||
WHERE workspace_id = $2 AND path = $3) AS "tgt_var!""#,
|
||
source_workspace_id,
|
||
fork_workspace_id,
|
||
path,
|
||
)
|
||
.fetch_one(db)
|
||
.await?;
|
||
|
||
// A workspace-specific variable keeps its own value per environment, so it never appears in the
|
||
// diff. Seeding the initial copy onto a side that lacks it is a separate explicit action.
|
||
if presence.src_ws || presence.tgt_ws {
|
||
return Ok(ItemComparison {
|
||
has_changes: false,
|
||
exists_in_source: presence.src_var,
|
||
exists_in_fork: presence.tgt_var,
|
||
});
|
||
}
|
||
|
||
// Get variable from each workspace
|
||
let source_variable = sqlx::query!(
|
||
"SELECT value, is_secret, description
|
||
FROM variable
|
||
WHERE workspace_id = $1 AND path = $2",
|
||
source_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_variable = sqlx::query!(
|
||
"SELECT value, is_secret, description
|
||
FROM variable
|
||
WHERE workspace_id = $1 AND path = $2",
|
||
fork_workspace_id,
|
||
path
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata differences
|
||
if let (Some(source), Some(target)) = (&source_variable, &target_variable) {
|
||
if source.is_secret != target.is_secret
|
||
|| source.value != target.value
|
||
|| source.description != target.description
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_variable.is_some() || target_variable.is_some() {
|
||
// The variable exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_variable.is_some(),
|
||
exists_in_fork: target_variable.is_some(),
|
||
});
|
||
}
|
||
|
||
async fn compare_two_resource_types(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
name: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Get resource type from each workspace
|
||
let source_resource_type = sqlx::query!(
|
||
"SELECT schema, description, format_extension, is_fileset
|
||
FROM resource_type
|
||
WHERE workspace_id = $1 AND name = $2",
|
||
source_workspace_id,
|
||
name
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_resource_type = sqlx::query!(
|
||
"SELECT schema, description, format_extension, is_fileset
|
||
FROM resource_type
|
||
WHERE workspace_id = $1 AND name = $2",
|
||
fork_workspace_id,
|
||
name
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata differences
|
||
if let (Some(source), Some(target)) = (&source_resource_type, &target_resource_type) {
|
||
if source.schema != target.schema
|
||
|| source.description != target.description
|
||
|| source.format_extension != target.format_extension
|
||
|| source.is_fileset != target.is_fileset
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_resource_type.is_some() || target_resource_type.is_some() {
|
||
// The resource type exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_resource_type.is_some(),
|
||
exists_in_fork: target_resource_type.is_some(),
|
||
});
|
||
}
|
||
|
||
async fn compare_two_folders(
|
||
db: &DB,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
name: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Get folder from each workspace
|
||
let source_folder = sqlx::query!(
|
||
"SELECT display_name, owners, extra_perms, summary
|
||
FROM folder
|
||
WHERE workspace_id = $1 AND name = $2",
|
||
source_workspace_id,
|
||
name.strip_prefix("f/"),
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let target_folder = sqlx::query!(
|
||
"SELECT display_name, owners, extra_perms, summary
|
||
FROM folder
|
||
WHERE workspace_id = $1 AND name = $2",
|
||
fork_workspace_id,
|
||
name.strip_prefix("f/"),
|
||
)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
let mut has_changes = false;
|
||
|
||
// Check metadata differences
|
||
if let (Some(source), Some(target)) = (&source_folder, &target_folder) {
|
||
if source.display_name != target.display_name
|
||
|| source.owners != target.owners
|
||
|| source.extra_perms != target.extra_perms
|
||
|| source.summary != target.summary
|
||
{
|
||
has_changes = true;
|
||
}
|
||
} else if source_folder.is_some() || target_folder.is_some() {
|
||
// The folder exists in one of source or target, but not the other, this is considered as a change
|
||
has_changes = true
|
||
}
|
||
|
||
return Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source_folder.is_some(),
|
||
exists_in_fork: target_folder.is_some(),
|
||
});
|
||
}
|
||
|
||
/// Fields stripped before comparing two trigger or schedule rows.
|
||
///
|
||
/// `mode` and `enabled` are forced to disabled/false on fork clone so they always
|
||
/// differ between fork and parent — comparing them would mark every cloned row as
|
||
/// "changed". The rest are runtime state (`server_id`, `last_server_ping`, `error`)
|
||
/// or per-row metadata that diverges naturally (`edited_at/by`, `email`, `extra_perms`,
|
||
/// `permissioned_as`). Comparing without these answers "is this trigger/schedule
|
||
/// configured the same way?" rather than "are the rows byte-identical?".
|
||
const TRIGGER_COMPARE_IGNORE: &[&str] = &[
|
||
"workspace_id",
|
||
"edited_by",
|
||
"edited_at",
|
||
"email",
|
||
"error",
|
||
"enabled",
|
||
"mode",
|
||
"server_id",
|
||
"last_server_ping",
|
||
"extra_perms",
|
||
"permissioned_as",
|
||
// Server-managed fields that the merge feature treats as workspace-local
|
||
// (regenerated by the deploy handler): GCP `subscription_id` is rewritten
|
||
// to `windmill_<workspace_id>_<path>` in `CreateUpdate` mode, and Azure's
|
||
// `push_auth_config` carries only the regenerated `secret_hash`. Without
|
||
// stripping, GCP/Azure push triggers stay flagged as "changed" forever.
|
||
"subscription_id",
|
||
"push_auth_config",
|
||
];
|
||
|
||
async fn compare_two_trigger_or_schedule(
|
||
db: &DB,
|
||
table: &str,
|
||
source_workspace_id: &str,
|
||
fork_workspace_id: &str,
|
||
path: &str,
|
||
) -> Result<ItemComparison> {
|
||
// Whitelist guard: callers in `compare_workspaces` and `query_visible_items`
|
||
// already match `table` against a closed set, but a stray future caller
|
||
// could open an injection hole. Bail loudly in debug, fail safe in release.
|
||
debug_assert!(
|
||
TRIGGER_OR_SCHEDULE_TABLES.contains(&table),
|
||
"compare_two_trigger_or_schedule called with unrecognized table: {table}"
|
||
);
|
||
if !TRIGGER_OR_SCHEDULE_TABLES.contains(&table) {
|
||
return Ok(ItemComparison {
|
||
has_changes: false,
|
||
exists_in_source: false,
|
||
exists_in_fork: false,
|
||
});
|
||
}
|
||
|
||
let mut select_expr = String::from("to_jsonb(t)");
|
||
for f in TRIGGER_COMPARE_IGNORE {
|
||
// The `-` operator on jsonb returns the object without the named key,
|
||
// or the unchanged object if the key is absent — so one ignore list
|
||
// works across tables with different column sets.
|
||
select_expr.push_str(&format!(" - '{f}'"));
|
||
}
|
||
// SAFETY: `table` comes from a hardcoded allowlist TRIGGER_OR_SCHEDULE_TABLES
|
||
// (guarded by the debug_assert + runtime check above), not user input.
|
||
// `select_expr` is built from `TRIGGER_COMPARE_IGNORE`, also a static const.
|
||
let sql = format!("SELECT {select_expr} FROM {table} t WHERE workspace_id = $1 AND path = $2");
|
||
|
||
let source_fut = sqlx::query_scalar::<_, serde_json::Value>(&sql)
|
||
.bind(source_workspace_id)
|
||
.bind(path)
|
||
.fetch_optional(db);
|
||
let target_fut = sqlx::query_scalar::<_, serde_json::Value>(&sql)
|
||
.bind(fork_workspace_id)
|
||
.bind(path)
|
||
.fetch_optional(db);
|
||
let (source, target) = tokio::try_join!(source_fut, target_fut)?;
|
||
|
||
let has_changes = match (source.as_ref(), target.as_ref()) {
|
||
(Some(s), Some(t)) => s != t,
|
||
(None, None) => false,
|
||
_ => true,
|
||
};
|
||
|
||
Ok(ItemComparison {
|
||
has_changes,
|
||
exists_in_source: source.is_some(),
|
||
exists_in_fork: target.is_some(),
|
||
})
|
||
}
|
||
|
||
const TRIGGER_OR_SCHEDULE_TABLES: &[&str] = &[
|
||
"schedule",
|
||
"http_trigger",
|
||
"websocket_trigger",
|
||
"kafka_trigger",
|
||
"nats_trigger",
|
||
"postgres_trigger",
|
||
"mqtt_trigger",
|
||
"sqs_trigger",
|
||
"gcp_trigger",
|
||
"azure_trigger",
|
||
"email_trigger",
|
||
];
|
||
|
||
const MAX_FEATURE_USAGE_EVENTS: usize = 50;
|
||
|
||
#[derive(Deserialize)]
|
||
struct FeatureUsageEvent {
|
||
feature: String,
|
||
kind: String,
|
||
#[serde(default)]
|
||
key: String,
|
||
#[serde(default)]
|
||
entity_id: String,
|
||
value: Option<i64>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct LogFeatureUsagePayload {
|
||
events: Vec<FeatureUsageEvent>,
|
||
}
|
||
|
||
async fn log_feature_usage(
|
||
Extension(db): Extension<DB>,
|
||
Json(payload): Json<LogFeatureUsagePayload>,
|
||
) -> Result<StatusCode> {
|
||
// Pre-sum duplicate keys: two rows hitting the same conflict target in a
|
||
// single INSERT error out ("cannot affect row a second time").
|
||
let mut agg: HashMap<(String, String, String, String), i64> = HashMap::new();
|
||
for e in payload.events.into_iter().take(MAX_FEATURE_USAGE_EVENTS) {
|
||
// Which actions may be recorded lives in
|
||
// `windmill_common::feature_usage`, shared with the in-process writer so
|
||
// both admit exactly the same events.
|
||
if !windmill_common::feature_usage::is_recordable_event(
|
||
&e.feature,
|
||
&e.kind,
|
||
&e.key,
|
||
&e.entity_id,
|
||
) {
|
||
continue;
|
||
}
|
||
let value = e.value.unwrap_or(1).clamp(1, 1_000_000);
|
||
*agg.entry((e.feature, e.kind, e.key, e.entity_id))
|
||
.or_insert(0) += value;
|
||
}
|
||
if agg.is_empty() {
|
||
return Ok(StatusCode::NO_CONTENT);
|
||
}
|
||
// Sorted for the same reason as `flush_feature_usage`: this endpoint and the
|
||
// backend flusher upsert the same rows, and two batches touching them in
|
||
// opposite orders deadlock.
|
||
let mut rows: Vec<((String, String, String, String), i64)> = agg.into_iter().collect();
|
||
rows.sort_unstable_by(|a, b| a.0.cmp(&b.0));
|
||
|
||
let mut features = Vec::with_capacity(rows.len());
|
||
let mut kinds = Vec::with_capacity(rows.len());
|
||
let mut keys = Vec::with_capacity(rows.len());
|
||
let mut entity_ids = Vec::with_capacity(rows.len());
|
||
let mut values = Vec::with_capacity(rows.len());
|
||
for ((feature, kind, key, entity_id), value) in rows {
|
||
features.push(feature);
|
||
kinds.push(kind);
|
||
keys.push(key);
|
||
entity_ids.push(entity_id);
|
||
values.push(value);
|
||
}
|
||
sqlx::query!(
|
||
"INSERT INTO feature_usage (feature, kind, key, entity_id, value)
|
||
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[], $5::bigint[])
|
||
ON CONFLICT (feature, kind, key, entity_id, day)
|
||
DO UPDATE SET value = feature_usage.value + EXCLUDED.value, updated_at = now()",
|
||
&features,
|
||
&kinds,
|
||
&keys,
|
||
&entity_ids,
|
||
&values
|
||
)
|
||
.execute(&db)
|
||
.await?;
|
||
Ok(StatusCode::NO_CONTENT)
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct QuotaInfo {
|
||
used: i64,
|
||
limit: i64,
|
||
prunable: i64,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct CloudQuotas {
|
||
scripts: QuotaInfo,
|
||
flows: QuotaInfo,
|
||
apps: QuotaInfo,
|
||
variables: QuotaInfo,
|
||
resources: QuotaInfo,
|
||
/// Fork/dev workspaces under this workspace's billing root vs the per-seat cap. `limit` is 0 for a
|
||
/// non-premium root (forking is premium-only). Family-wide: resolves to the billing root, so it
|
||
/// reads the same whether viewed from the root or one of its forks.
|
||
forks: QuotaInfo,
|
||
}
|
||
|
||
async fn get_cloud_quotas(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<CloudQuotas> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
if !*CLOUD_HOSTED {
|
||
return Err(Error::BadRequest(
|
||
"Cloud quotas are only available on cloud-hosted instances".to_string(),
|
||
));
|
||
}
|
||
|
||
let scripts_used =
|
||
sqlx::query_scalar!("SELECT COUNT(*) FROM script WHERE workspace_id = $1", &w_id)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let scripts_prunable = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (
|
||
SELECT DISTINCT ON (path) hash FROM script
|
||
WHERE workspace_id = $1 AND deleted = false
|
||
ORDER BY path, created_at DESC
|
||
)",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let flows_used =
|
||
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let flows_prunable = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM flow_version fv
|
||
JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path
|
||
WHERE fv.workspace_id = $1 AND fv.id != f.versions[array_upper(f.versions, 1)]",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let apps_used = sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let apps_prunable = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM app_version av
|
||
JOIN app a ON a.id = av.app_id
|
||
WHERE a.workspace_id = $1 AND av.id != a.versions[array_upper(a.versions, 1)]",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
// Every path keeps exactly one current version, so the prunable count is the total minus
|
||
// the number of distinct paths — one scan rather than a probe per row.
|
||
let resources_prunable = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) - COUNT(DISTINCT path) FROM resource_version WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let variables_used = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
let resources_used = sqlx::query_scalar!(
|
||
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
|
||
&w_id
|
||
)
|
||
.fetch_one(&db)
|
||
.await?
|
||
.unwrap_or(0);
|
||
|
||
// Fork/dev workspaces vs the per-seat cap, resolved to the billing root. Non-premium roots can't
|
||
// fork, so their allowance is 0.
|
||
#[cfg(feature = "cloud")]
|
||
let forks = {
|
||
use windmill_common::workspaces::{
|
||
count_paid_seats, count_workspace_forks, get_billing_workspace_id, get_team_plan_status,
|
||
};
|
||
let root = get_billing_workspace_id(&db, &w_id).await?;
|
||
let used = count_workspace_forks(&db, &root).await?;
|
||
let limit = if get_team_plan_status(&db, &root).await?.premium {
|
||
count_paid_seats(&db, &root).await?.max(1) * *MAX_FORKS_PER_SEAT
|
||
} else {
|
||
0
|
||
};
|
||
QuotaInfo { used, limit, prunable: 0 }
|
||
};
|
||
#[cfg(not(feature = "cloud"))]
|
||
let forks = QuotaInfo { used: 0, limit: 0, prunable: 0 };
|
||
|
||
Ok(Json(CloudQuotas {
|
||
scripts: QuotaInfo { used: scripts_used, limit: 5000, prunable: scripts_prunable },
|
||
flows: QuotaInfo { used: flows_used, limit: 1000, prunable: flows_prunable },
|
||
apps: QuotaInfo { used: apps_used, limit: 1000, prunable: apps_prunable },
|
||
variables: QuotaInfo { used: variables_used, limit: 10000, prunable: 0 },
|
||
resources: QuotaInfo { used: resources_used, limit: 10000, prunable: resources_prunable },
|
||
forks,
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct PruneVersionsRequest {
|
||
resource_type: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct PruneVersionsResponse {
|
||
pruned: u64,
|
||
}
|
||
|
||
async fn prune_versions(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Json(req): Json<PruneVersionsRequest>,
|
||
) -> JsonResult<PruneVersionsResponse> {
|
||
require_admin(authed.is_admin, &authed.username)?;
|
||
|
||
if !*CLOUD_HOSTED {
|
||
return Err(Error::BadRequest(
|
||
"Version pruning is only available on cloud-hosted instances".to_string(),
|
||
));
|
||
}
|
||
|
||
let pruned = match req.resource_type.as_str() {
|
||
"scripts" => {
|
||
let result = sqlx::query(
|
||
"DELETE FROM script
|
||
WHERE workspace_id = $1 AND hash NOT IN (
|
||
SELECT DISTINCT ON (path) hash FROM script
|
||
WHERE workspace_id = $1 AND deleted = false
|
||
ORDER BY path, created_at DESC
|
||
)",
|
||
)
|
||
.bind(&w_id)
|
||
.execute(&db)
|
||
.await?;
|
||
result.rows_affected()
|
||
}
|
||
"flows" => {
|
||
let deleted = sqlx::query(
|
||
"DELETE FROM flow_version fv
|
||
USING flow f
|
||
WHERE fv.workspace_id = f.workspace_id AND fv.path = f.path
|
||
AND fv.workspace_id = $1
|
||
AND fv.id != f.versions[array_upper(f.versions, 1)]",
|
||
)
|
||
.bind(&w_id)
|
||
.execute(&db)
|
||
.await?;
|
||
|
||
sqlx::query(
|
||
"UPDATE flow SET versions = ARRAY[versions[array_upper(versions, 1)]]
|
||
WHERE workspace_id = $1 AND array_length(versions, 1) > 1",
|
||
)
|
||
.bind(&w_id)
|
||
.execute(&db)
|
||
.await?;
|
||
|
||
deleted.rows_affected()
|
||
}
|
||
"apps" => {
|
||
let deleted = sqlx::query(
|
||
"DELETE FROM app_version av
|
||
USING app a
|
||
WHERE av.app_id = a.id AND a.workspace_id = $1
|
||
AND av.id != a.versions[array_upper(a.versions, 1)]",
|
||
)
|
||
.bind(&w_id)
|
||
.execute(&db)
|
||
.await?;
|
||
|
||
sqlx::query(
|
||
"UPDATE app SET versions = ARRAY[versions[array_upper(versions, 1)]]
|
||
WHERE workspace_id = $1 AND array_length(versions, 1) > 1",
|
||
)
|
||
.bind(&w_id)
|
||
.execute(&db)
|
||
.await?;
|
||
|
||
deleted.rows_affected()
|
||
}
|
||
"resources" => {
|
||
// No `versions` array to rewrite afterwards, unlike flows and apps: the latest
|
||
// version is whichever row has the highest id for the path.
|
||
let deleted = sqlx::query(
|
||
"DELETE FROM resource_version
|
||
WHERE workspace_id = $1
|
||
AND id NOT IN (
|
||
SELECT max(id) FROM resource_version
|
||
WHERE workspace_id = $1 GROUP BY path
|
||
)",
|
||
)
|
||
.bind(&w_id)
|
||
.execute(&db)
|
||
.await?;
|
||
deleted.rows_affected()
|
||
}
|
||
_ => {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid resource type '{}'. Must be 'scripts', 'flows', 'apps', or 'resources'",
|
||
req.resource_type
|
||
)));
|
||
}
|
||
};
|
||
|
||
Ok(Json(PruneVersionsResponse { pruned }))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct WsSpecificItem {
|
||
item_kind: String,
|
||
path: String,
|
||
}
|
||
|
||
async fn list_ws_specific(
|
||
authed: ApiAuthed,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Path(w_id): Path<String>,
|
||
) -> JsonResult<Vec<WsSpecificItem>> {
|
||
// ws_specific itself has no per-item RLS — only the workspace_id column.
|
||
// Joining against resource/variable under user_db forces the same
|
||
// path-based RLS policies that govern those tables (see_own / see_member /
|
||
// see_extra_perms_* / see_folder_extra_perms_user) to also gate visibility
|
||
// here. Without these joins, any workspace member could enumerate paths
|
||
// in folders they lack read access to (e.g. f/finance/prod_db_creds).
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
let items = sqlx::query_as!(
|
||
WsSpecificItem,
|
||
r#"
|
||
SELECT s.item_kind, s.path
|
||
FROM ws_specific s
|
||
WHERE s.workspace_id = $1
|
||
AND (
|
||
(s.item_kind = 'resource' AND EXISTS (
|
||
SELECT 1 FROM resource r
|
||
WHERE r.workspace_id = s.workspace_id AND r.path = s.path
|
||
))
|
||
OR (s.item_kind = 'variable' AND EXISTS (
|
||
SELECT 1 FROM variable v
|
||
WHERE v.workspace_id = s.workspace_id AND v.path = s.path
|
||
))
|
||
)
|
||
ORDER BY s.item_kind, s.path
|
||
"#,
|
||
&w_id
|
||
)
|
||
.fetch_all(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
// RLS gates membership/folder access, but a scoped API token must also be held to its read
|
||
// scopes — mirror the resource/variable list endpoints, which filter with these predicates so a
|
||
// token lacking `resources:read:*` / `variables:read:*` can't enumerate pinned paths it can't read.
|
||
let resource_allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||
let variable_allowed = build_scope_path_predicate(&authed, "variables", "read");
|
||
let items = items
|
||
.into_iter()
|
||
.filter(|it| match it.item_kind.as_str() {
|
||
"resource" => resource_allowed(&it.path),
|
||
"variable" => variable_allowed(&it.path),
|
||
_ => false,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Ok(Json(items))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ListWsSpecificVersionsQuery {
|
||
kind: String,
|
||
path: String,
|
||
}
|
||
|
||
async fn list_ws_specific_versions(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Path(w_id): Path<String>,
|
||
Query(q): Query<ListWsSpecificVersionsQuery>,
|
||
) -> JsonResult<Vec<String>> {
|
||
if q.kind != "resource" && q.kind != "variable" {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid kind '{}'. Must be 'resource' or 'variable'",
|
||
q.kind
|
||
)));
|
||
}
|
||
|
||
// A scoped API token must hold the read scope for this path, like the resource/variable read
|
||
// endpoints. Without the scope, report no versions rather than leaking the path's history.
|
||
let domain = if q.kind == "resource" {
|
||
"resources"
|
||
} else {
|
||
"variables"
|
||
};
|
||
if !build_scope_path_predicate(&authed, domain, "read")(&q.path) {
|
||
return Ok(Json(vec![]));
|
||
}
|
||
|
||
let versions: Vec<String> = sqlx::query_scalar!(
|
||
r#"SELECT ws AS "ws!" FROM list_ws_specific_versions($1, $2, $3, $4)"#,
|
||
&w_id,
|
||
&authed.email,
|
||
&q.kind,
|
||
&q.path,
|
||
)
|
||
.fetch_all(&db)
|
||
.await?;
|
||
|
||
Ok(Json(versions))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct SetWsSpecificBody {
|
||
item_kind: String,
|
||
path: String,
|
||
value: bool,
|
||
}
|
||
|
||
/// Mark (or unmark) a single resource/variable as workspace-specific. Pinning
|
||
/// excludes it from the deploy diff so each environment keeps its own value
|
||
/// (see `compare_two_resources`/`compare_two_variables`). Set per-workspace, so
|
||
/// the compare page calls this once per side to flag both environments.
|
||
async fn set_ws_specific(
|
||
authed: ApiAuthed,
|
||
Extension(db): Extension<DB>,
|
||
Extension(user_db): Extension<UserDB>,
|
||
Path(w_id): Path<String>,
|
||
Json(body): Json<SetWsSpecificBody>,
|
||
) -> Result<String> {
|
||
if body.item_kind != "resource" && body.item_kind != "variable" {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid kind '{}'. Must be 'resource' or 'variable'",
|
||
body.item_kind
|
||
)));
|
||
}
|
||
|
||
// Reject a malformed path with a 400 before the auth check, which indexes the leading segments and
|
||
// would otherwise panic (500) on a path like `u` with no segment. Accept all three shared path
|
||
// shapes Windmill uses — `u/<user>`, `f/<folder>`, `g/<group>` (e.g. seeded `g/all/...` resources).
|
||
let segs: Vec<&str> = body.path.split('/').collect();
|
||
if segs.len() < 2 || !matches!(segs[0], "u" | "f" | "g") || segs[1].is_empty() {
|
||
return Err(Error::BadRequest(format!(
|
||
"Invalid {} path: {}",
|
||
body.item_kind, body.path
|
||
)));
|
||
}
|
||
|
||
// Authorize like the resource/variable editors' own ws_specific toggle:
|
||
// actual write access to the item + token scope + the workspace deploy rules.
|
||
// `require_owner_of_path` is the real write gate (the resource editor uses it);
|
||
// `check_scopes` only constrains scoped tokens (it is a no-op for session/cookie
|
||
// logins). Together: a non-admin who can edit the item may pin it, while a
|
||
// read-only member is rejected and a locked workspace still blocks non-deployers.
|
||
// `require_is_writer` matches the resource/variable editors' write semantics (owner, folder
|
||
// writer, or item writer via extra_perms) — not owner-only.
|
||
let writer_query = if body.item_kind == "resource" {
|
||
"SELECT extra_perms FROM resource WHERE path = $1 AND workspace_id = $2"
|
||
} else {
|
||
"SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2"
|
||
};
|
||
require_is_writer(
|
||
&authed,
|
||
&body.path,
|
||
&w_id,
|
||
db.clone(),
|
||
writer_query,
|
||
&body.item_kind,
|
||
)
|
||
.await?;
|
||
check_scopes(&authed, || {
|
||
format!("{}s:write:{}", body.item_kind, body.path)
|
||
})?;
|
||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||
&w_id,
|
||
&authed.username,
|
||
&authed.groups,
|
||
authed.is_admin,
|
||
&db,
|
||
)
|
||
.await?
|
||
{
|
||
return Err(Error::PermissionDenied(msg));
|
||
}
|
||
|
||
let mut tx = user_db.begin(&authed).await?;
|
||
|
||
if body.value {
|
||
// Existence guard keeps a dangling marker from being created for a
|
||
// path absent in this workspace.
|
||
if body.item_kind == "resource" {
|
||
sqlx::query!(
|
||
"INSERT INTO ws_specific (workspace_id, item_kind, path)
|
||
SELECT $1::varchar, 'resource', $2::varchar
|
||
WHERE EXISTS (SELECT 1 FROM resource WHERE workspace_id = $1::varchar AND path = $2::varchar)
|
||
ON CONFLICT DO NOTHING",
|
||
w_id,
|
||
body.path,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
// A resource owns its `$var:` secrets, so pin those too.
|
||
windmill_store::resources::mark_linked_variables_ws_specific(
|
||
&mut tx, &authed, &w_id, &body.path,
|
||
)
|
||
.await?;
|
||
} else {
|
||
sqlx::query!(
|
||
"INSERT INTO ws_specific (workspace_id, item_kind, path)
|
||
SELECT $1::varchar, 'variable', $2::varchar
|
||
WHERE EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1::varchar AND path = $2::varchar)
|
||
ON CONFLICT DO NOTHING",
|
||
w_id,
|
||
body.path,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
} else {
|
||
// Unmark only this item; linked variables stay flagged (they may be
|
||
// referenced by other resources) — mirrors the resource-form toggle.
|
||
sqlx::query!(
|
||
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = $2 AND path = $3",
|
||
w_id,
|
||
body.item_kind,
|
||
body.path,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
// While pinned, the item's cached workspace_diff verdict was never recomputed (the compare
|
||
// read filter excludes it), so it may now be stale in either direction. Mark it NULL so the
|
||
// next compare re-evaluates from scratch — and the now-shared item reappears (or is dropped)
|
||
// correctly instead of being stuck on its pre-pin verdict.
|
||
sqlx::query!(
|
||
"UPDATE workspace_diff SET has_changes = NULL
|
||
WHERE path = $2 AND kind = $3
|
||
AND ($1 IN (source_workspace_id, fork_workspace_id))",
|
||
w_id,
|
||
body.path,
|
||
body.item_kind,
|
||
)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
}
|
||
|
||
let value_str = body.value.to_string();
|
||
audit_log(
|
||
&mut *tx,
|
||
&authed,
|
||
&format!("{}s.set_ws_specific", body.item_kind),
|
||
ActionKind::Update,
|
||
&w_id,
|
||
Some(&body.path),
|
||
Some([("value", value_str.as_str())].into()),
|
||
)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
Ok(format!(
|
||
"Set workspace-specific={} for {} {}",
|
||
body.value, body.item_kind, body.path
|
||
))
|
||
}
|