Compare commits

..
Author SHA1 Message Date
Diego ImbertandClaude Opus 5 cd4f6edad3 fix(datatables): accept external_instance data tables in the settings form type
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:14:05 +02:00
Diego Imbert 80a2accf46 Merge remote-tracking branch 'origin/datatable-external-instance' into datatable-external-instance-2
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-18 15:07:40 +02:00
Diego Imbert 37925d110c Merge remote-tracking branch 'origin/fork-database-authorization' into datatable-external-instance-2 2026-09-18 15:01:24 +02:00
Diego ImbertandClaude Opus 5 5aa09bc871 chore: drop the unused json import from the settings crate
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:01:17 +02:00
Diego Imbert b337ade93b Merge remote-tracking branch 'origin/datatable-roles-redesign-part-4' into datatable-external-instance
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-18 15:00:57 +02:00
Diego Imbert 89bee36774 Merge remote-tracking branch 'origin/datatable-roles-redesign-part-2' into datatable-roles-redesign-part-4
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-api-integration-tests/tests/datatable_roles.rs
#	backend/windmill-api-workspaces/src/datatable_permissions_oss.rs
#	backend/windmill-api-workspaces/src/workspaces.rs
#	backend/windmill-common/src/datatable_roles_oss.rs
#	backend/windmill-common/src/workspaces.rs
#	frontend/src/lib/components/datatableAcl/PgAclEditor.svelte
#	frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte
#	frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte
#	frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte
#	frontend/src/lib/components/workspaceSettings/InstanceRolesButton.svelte
2026-09-18 14:11:39 +02:00
Diego Imbert b8060cdd41 Merge commit '45e24e509862ecbd87ad9ab7d90910f9a5a163ad' into HEAD
# Conflicts:
#	backend/windmill-api-workspaces/src/workspaces.rs
#	backend/windmill-api-workspaces/src/workspaces_extra.rs
2026-09-18 14:05:12 +02:00
Diego Imbert 45e24e5098 Merge remote-tracking branch 'origin/fork-database-authorization' into HEAD 2026-09-18 14:04:12 +02:00
windmill-internal-app[bot] 64f95bcb7b 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: 0edd40979cf36bfba59323f3f6a0811ae1369cf5

New ee-repo-ref: 7e338e4dabf91689bfd7fb0333c6534040b17b59

Automated by sync-ee-ref workflow.
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 908b98ef78 fix: check ACL read reach against the entry it connects from
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 de7be97124 fix: build the ACL connection from the authorized data table entry
Resolving the settings again could land on a resource with the same
database name on another server, which the later entry checks never see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 0f99cce43c fix: hold the ACL connection to the database that was authorized
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 d985f5d0ff fix: run one data table ACL apply at a time per server before it connects
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 9b9d164524 fix: add only missing grant options before an ACL apply, never default privileges
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 45dd4cb271 fix: refresh grant options only after the ACL apply validates its plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 f0f1f7c186 fix: take every pooled connection before the ACL apply locks
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 76b796bc62 feat(datatables): add an ACL editor for data table roles
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:00:05 +02:00
Diego ImbertandClaude Opus 5 fc29b54ae6 fix(datatables): check the external cluster setting on its own transaction, and gate the registry probe
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:58:58 +02:00
Diego ImbertandClaude Opus 5 92c044af6a fix(datatables): hold the fork lock across a fork import, and carry the reservation inside the setup write
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:57:18 +02:00
Diego ImbertandClaude Opus 5 0f7cadb19c fix(datatables): keep fork reservations private, drop a cleaned-up entry with its database, and serialize cleanup with settings saves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 01:11:43 +02:00
Diego ImbertandClaude Opus 5 8c554ef133 fix(datatables): take the fork data table lock once, before the external cluster's
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 01:00:06 +02:00
Diego Imbert cdd681c029 Merge commit '154bade187169ec5c403d300a160556b768a0ead' into HEAD 2026-09-18 00:58:35 +02:00
Diego Imbert 154bade187 Merge remote-tracking branch 'origin/fork-database-authorization' into HEAD 2026-09-18 00:58:33 +02:00
Diego ImbertandClaude Opus 5 e550374f06 fix(datatables): migrate fork reservations on workspace rename, and lock the parent's data tables for the whole fork
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:58:19 +02:00
Diego ImbertandClaude Opus 5 addd77eaf4 fix(datatables): create external databases only on a cluster setup succeeded on, and document the registry reader
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:43:49 +02:00
Diego Imbert 7f5628e775 Merge commit 'eab084b36fe14ecee3ecc9a78fb079760de133b9' into HEAD
# Conflicts:
#	backend/windmill-common/src/lib.rs
2026-09-17 18:43:49 +02:00
Diego Imbert eab084b36f Merge remote-tracking branch 'origin/fork-database-authorization' into HEAD 2026-09-17 18:41:27 +02:00
Diego ImbertandClaude Opus 5 4cba79b7f0 fix(datatables): authenticate instance database setup before writing its status, and keep a fork reservation across it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:41:11 +02:00
Diego Imbert 76a10f8ee8 Merge commit 'c9e2246e1c2480135884c9b3dcace11da0907dba' into HEAD
# Conflicts:
#	backend/windmill-api-workspaces/src/workspaces.rs
#	backend/windmill-api-workspaces/src/workspaces_extra.rs
2026-09-17 18:21:18 +02:00
Diego Imbert c9e2246e1c Merge remote-tracking branch 'origin/fork-database-authorization' into HEAD 2026-09-17 18:17:06 +02:00
Diego ImbertandClaude Opus 5 b68768084e fix(datatables): bind fork database copies to their workspace, and count every use before dropping one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:16:48 +02:00
Diego ImbertandClaude Opus 5 7c16d482fd fix(datatables): refuse fork cleanup of an external database another workspace uses
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:34:14 +02:00
Diego ImbertandClaude Opus 5 5ddaee5b78 fix(datatables): write the external cluster setting under the lifecycle lock, and check fork targets are registered
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:04:18 +02:00
Diego ImbertandClaude Opus 5 3981b2eb1b fix(datatables): refuse repointing the external cluster while it is in use, and keep verify-ca working for pg_dump
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:28:31 +02:00
Diego Imbert a928253af0 Merge remote-tracking branch 'origin/datatable-roles-redesign-part-2' into datatable-roles-redesign-part-4
# Conflicts:
#	backend/windmill-common/src/workspaces.rs
2026-09-17 15:22:45 +02:00
Diego Imbert 5d8f031cf6 Merge branch 'datatable-roles-redesign' into datatable-roles-redesign-part-2 2026-09-17 15:22:26 +02:00
Diego ImbertandClaude Opus 5 8a7f364cfe 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>
2026-09-17 13:29:35 +02:00
Diego ImbertandClaude Opus 5 ee176e24d5 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>
2026-09-17 13:04:26 +02:00
Diego ImbertandClaude Opus 5 4bdc5a9e20 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>
2026-09-17 12:17:30 +02:00
Diego ImbertandClaude Opus 5 9b0046d867 docs(datatables): state the authorization contract of external database usage lookups
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:50:10 +02:00
Diego Imbert 1f78ce77f5 Merge commit 'dbe0230f483bbe10aeca055c4d39506fea41459d' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 11:50:00 +02:00
Diego ImbertandClaude Opus 5 dbe0230f48 fix(datatables): never grant CREATEROLE to custom_instance_user on the external cluster
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:49:20 +02:00
Diego Imbert ced4913b33 Merge commit 'b3a3f1cf5a0e6f97af2679c8abf640c6b832d7db' into datatable-external-instance-2
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 11:34:32 +02:00
Diego Imbert b3a3f1cf5a Merge remote-tracking branch 'origin/datatable-roles-redesign-part-4' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 11:34:28 +02:00
Diego Imbert 92e51f19aa Merge remote-tracking branch 'origin/datatable-roles-redesign-part-2' into datatable-roles-redesign-part-4
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-api-integration-tests/tests/datatable_roles.rs
#	backend/windmill-api-workspaces/src/datatable_permissions_oss.rs
#	backend/windmill-api-workspaces/src/workspaces.rs
#	backend/windmill-common/src/datatable_roles_oss.rs
#	backend/windmill-common/src/workspaces.rs
#	frontend/src/lib/components/datatableAcl/PgAclEditor.svelte
#	frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte
#	frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte
#	frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte
#	frontend/src/lib/components/workspaceSettings/InstanceRolesButton.svelte
2026-09-17 10:03:05 +02:00
Diego ImbertandClaude Opus 5 36532c35d8 fix: check ACL read reach against the entry it connects from
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 79481a2f02 fix: build the ACL connection from the authorized data table entry
Resolving the settings again could land on a resource with the same
database name on another server, which the later entry checks never see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 c884597e56 fix: hold the ACL connection to the database that was authorized
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 115448d3d1 fix: run one data table ACL apply at a time per server before it connects
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 1ece278fd2 fix: add only missing grant options before an ACL apply, never default privileges
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 7819d48df9 fix: refresh grant options only after the ACL apply validates its plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 ce1f720d85 fix: take every pooled connection before the ACL apply locks
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 93d346e54b feat(datatables): add an ACL editor for data table roles
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:19 +02:00
Diego ImbertandClaude Opus 5 9378828472 docs(datatables): note that legacy names with ? cannot be migrated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 79bb761c5d fix(datatables): serialize roles going on with aliases saved from other workspaces
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 d191cfe4eb fix(datatables): let CE migrations connect as an explicitly named admin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 31b5b0a062 fix(datatables): refuse roles while a same-workspace alias reaches the database
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:15 +02:00
windmill-internal-app[bot] d1c2f801f4 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.
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 d3ee67c9bc 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 eafc551651 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 db05c8b7c9 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 11ba3cd477 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 7ae3335f03 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 c7269e975f 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 8a0babd7a6 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 6d9ef5f6b7 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 1dbc1e7759 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 df471e37d8 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 122a3bd164 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 2f95c22242 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 7279ee15e2 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 fa1e8bd3aa 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 dc3ebfbe74 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 8255f2ad2b 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 ebe076cbe6 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 643bc1b51a 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 29fccb0978 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>
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 432ccc0fbd 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 e6794984d2 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 2abcd122a8 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 30f039d292 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 5fd9ab24b8 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 f7812187cc 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 b5785e8e7a 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 ac8587e452 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
2026-09-17 10:01:15 +02:00
Diego Imbert 3ae1688c44 fix pg_dump stuck on version 17 on nix 2026-09-17 10:01:15 +02:00
Diego Imbert d69da0bbd6 nit worker error msg 2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 eeed332591 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 78a12fdfcc 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 189bc3297b 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 0efddb94cf 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 1db77dda87 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 d0ea1ce598 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 c400469e67 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 a74f73ccb1 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 a7bff8de97 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 0d5106f618 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 b8d6b3c953 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 c2c24598a2 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 21d97f3a4c 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
2026-09-17 10:01:15 +02:00
Diego ImbertandClaude Opus 5 34ed0c4230 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
2026-09-17 10:01:15 +02:00
Diego Imbert 13162656bf Merge remote-tracking branch 'origin/datatable-roles-redesign-part-2' into datatable-roles-redesign-part-4
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-api-workspaces/src/datatable_permissions_oss.rs
2026-09-17 09:50:52 +02:00
Diego ImbertandClaude Opus 5 e79bc14903 fix: check ACL read reach against the entry it connects from
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 09:00:04 +02:00
Diego ImbertandClaude Opus 5 b7623f12eb fix: build the ACL connection from the authorized data table entry
Resolving the settings again could land on a resource with the same
database name on another server, which the later entry checks never see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-17 00:09:29 +02:00
Diego ImbertandClaude Opus 5 955aadbf10 fix(datatables): only drop external databases Windmill marked, and check use under the lock
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:08:15 +02:00
Diego Imbert a41644c795 Merge remote-tracking branch 'origin/datatable-external-instance' into datatable-external-instance-2
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 00:08:14 +02:00
Diego ImbertandClaude Opus 5 631c01d20d fix(datatables): compare the external cluster settings under a row lock before storing setup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:07:50 +02:00
Diego ImbertandClaude Opus 5 0a2ebfb3db fix: hold the ACL connection to the database that was authorized
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-16 23:55:43 +02:00
Diego Imbert 0489cb1630 Merge remote-tracking branch 'origin/datatable-roles-redesign-part-2' into datatable-roles-redesign-part-4
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-api-integration-tests/tests/datatable_roles.rs
2026-09-16 23:45:48 +02:00
Diego ImbertandClaude Opus 5 455e1b8d23 [ee] feat(datatables): external_instance data tables on the external cluster
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:43:10 +02:00
Diego ImbertandClaude Opus 5 d7417f5cc2 Merge branch 'datatable-roles-redesign' into datatable-roles-redesign-part-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-16 23:40:04 +02:00
Diego ImbertandClaude Opus 5 5ef5f58eeb fix(datatables): scope external cluster credential readers to the crate
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:38:06 +02:00
Diego ImbertandClaude Opus 5 4533ec460e fix(datatables): send external cluster passwords as SCRAM verifiers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:44:11 +02:00
Diego ImbertandClaude Opus 5 b3cda2f548 feat(datatables): set up an external instance cluster for data tables
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:35:00 +02:00
Diego ImbertandClaude Opus 5 8d0d51e99d fix: run one data table ACL apply at a time per server before it connects
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-16 22:25:51 +02:00
Diego ImbertandClaude Opus 5 110876d0c9 docs(datatables): note that legacy names with ? cannot be migrated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:25:27 +02:00
Diego ImbertandClaude Opus 5 e5ec5217d4 fix(datatables): serialize roles going on with aliases saved from other workspaces
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:09:07 +02:00
Diego ImbertandClaude Opus 5 6a89ba0179 fix: add only missing grant options before an ACL apply, never default privileges
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-16 17:31:06 +02:00
Diego ImbertandClaude Opus 5 707b0dc8ee fix(datatables): let CE migrations connect as an explicitly named admin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:25:42 +02:00
Diego ImbertandClaude Opus 5 dcc37bf6f2 fix: declare the default role in migrations written for a data table whose name contains '?'
Such a data table connects as its default role without naming it, so the
migrations the manager wrote for it declared no role and ran as admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:20:19 +02:00
Diego ImbertandClaude Opus 5 10861bb529 fix(datatables): refuse a reference naming both a legacy data table and a role
When a workspace stores both `sales` and a legacy `sales?role=analytics`, the
reference resolved to the legacy entry without a role, so browsing `sales` as
`analytics` reached another data table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:03:32 +02:00
Diego ImbertandClaude Opus 5 d459b371d8 fix: refresh grant options only after the ACL apply validates its plan
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-16 16:53:13 +02:00
Diego ImbertandClaude Opus 5 41e1101a2d fix: take every pooled connection before the ACL apply locks
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
2026-09-16 16:39:14 +02:00
Diego ImbertandClaude Opus 5 4416283b17 fix: read the roles of a data table whose name contains '?'
The generated client leaves a '?' in a path param unencoded, so the lookup
404'd and the raw-app picker blocked Start on such a data table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:32:36 +02:00
Diego ImbertandClaude Opus 5 e0360bce5c fix: never add a role to the reference of a data table whose name contains '?'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:07:00 +02:00
Diego ImbertandClaude Opus 5 f36aa69fc3 feat(datatables): data table roles in the DB manager and raw apps
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:23:20 +02:00
Diego ImbertandClaude Opus 5 a1b91690fd feat(datatables): add an ACL editor for data table roles
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:22:05 +02:00
Diego ImbertandClaude Opus 5 0676f55856 fix(datatables): refuse roles while a same-workspace alias reaches the database
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:14:29 +02:00
windmill-internal-app[bot] aa6a52271b 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.
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 64d5f762dc 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 4c2f1a7b60 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 16fdba4d82 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 1986ec2535 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 a7bac22c13 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 1d6da0084e 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 3d4a2133ed 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 f8a6347052 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 46dfcc5c49 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 e9c27d63db 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 65a6cc3eb5 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 dd2ca5513e 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 6a3a2ed13c 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 791bb8d843 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 b68b97f5ea 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 92c723f735 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 c7f28ba8ed 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 bea9667516 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 bb8c8ed735 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>
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 4610f8a35c 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 284d1ecb43 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
2026-09-16 15:14:29 +02:00
Diego ImbertandClaude Opus 5 306a35e3b0 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 5a98030fb7 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 d558b6508a 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 fce20faeae 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 da3babccc7 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 69742c0b48 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
2026-09-16 15:14:28 +02:00
Diego Imbert 3dfbf64d9d fix pg_dump stuck on version 17 on nix 2026-09-16 15:14:28 +02:00
Diego Imbert d32ff92eac nit worker error msg 2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 01eba26582 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 cafcf3afc3 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 030254af47 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 9872bff69f 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 e400daabb8 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 7f3c7a19af 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 1d9ee09b31 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 715d8a0e6d 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 ea14c01a3c 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 54f4467027 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 8276ae09fa 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 6f9457fc8e 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 6ea682d741 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
2026-09-16 15:14:28 +02:00
Diego ImbertandClaude Opus 5 22b3e69c07 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
2026-09-16 15:14:28 +02:00
302 changed files with 15100 additions and 12468 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.815.0"
".": "1.814.0"
}
-34
View File
@@ -1,39 +1,5 @@
# Changelog
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
### Features
* add a workspace toggle that adds its admins and developers to new forks ([#11215](https://github.com/windmill-labs/windmill/issues/11215)) ([9d335de](https://github.com/windmill-labs/windmill/commit/9d335de87a4dbaa51038d55afe8d980761dcdfaf))
* add an instance setting to refuse a token in MCP URLs ([#11162](https://github.com/windmill-labs/windmill/issues/11162)) ([37e493a](https://github.com/windmill-labs/windmill/commit/37e493ae66ed5c000ecac492d60fc0fdf4bda71f))
* **ai-chat:** render get_run through the run tools' card ([#11204](https://github.com/windmill-labs/windmill/issues/11204)) ([6f9c4dc](https://github.com/windmill-labs/windmill/commit/6f9c4dc29455d13b0e64af05c2d6aa8bd5ff4fd6))
* **ai-chat:** tell the chat which kind of app it is looking at ([#11208](https://github.com/windmill-labs/windmill/issues/11208)) ([c8c06d8](https://github.com/windmill-labs/windmill/commit/c8c06d8f79774abf109192e71a8b6fc37c7937ba))
* attach files to a flow chat message ([#11185](https://github.com/windmill-labs/windmill/issues/11185)) ([c4c9677](https://github.com/windmill-labs/windmill/commit/c4c9677982b75c63d98ebf85b1904e0c341ba957))
* **auth:** 2 h login links and a click-to-sign-in page for emailed ones ([#11203](https://github.com/windmill-labs/windmill/issues/11203)) ([5639187](https://github.com/windmill-labs/windmill/commit/5639187fec6d517a72e82df49d63d7438301127c))
* cap user token expiration with an instance setting ([#11159](https://github.com/windmill-labs/windmill/issues/11159)) ([9320312](https://github.com/windmill-labs/windmill/commit/9320312eac56f944c4d31504601293ab4e816ccc))
* flow chat job-backed detail, smooth streaming and answer chrome ([#11186](https://github.com/windmill-labs/windmill/issues/11186)) ([e2a91ca](https://github.com/windmill-labs/windmill/commit/e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35))
* put a data table's connection under Postgres roles ([#11020](https://github.com/windmill-labs/windmill/issues/11020)) ([0e807fb](https://github.com/windmill-labs/windmill/commit/0e807fb1dd80d7536ec144cd49445abc7961e504))
* render an AI agent result as its answer, not as raw JSON ([#11051](https://github.com/windmill-labs/windmill/issues/11051)) ([a089928](https://github.com/windmill-labs/windmill/commit/a08992834d45d0211336f4fc32c3421646ca47c5))
* support $flow_expr[...] dynamic tags on flow steps ([#11170](https://github.com/windmill-labs/windmill/issues/11170)) ([48f0025](https://github.com/windmill-labs/windmill/commit/48f00259c5e7361d3553dbcb809461e1cde96f8f))
### Bug Fixes
* **frontend:** inline only the package version, not the whole package.json ([#11191](https://github.com/windmill-labs/windmill/issues/11191)) ([813e486](https://github.com/windmill-labs/windmill/commit/813e486e166ac6215364817a7732b66f8dc1d463))
* keep instance groups when editing auto-invite ([#11217](https://github.com/windmill-labs/windmill/issues/11217)) ([df61dea](https://github.com/windmill-labs/windmill/commit/df61dea5fa8b18d1e0044dc0db6702b053d5119f))
* key the large root font size on screen width, not window width ([#11216](https://github.com/windmill-labs/windmill/issues/11216)) ([3b4e13d](https://github.com/windmill-labs/windmill/commit/3b4e13d1c564c6195e30b55b0671f7533e3ce408))
* re-encrypt git sync secrets on workspace key rotation ([#11218](https://github.com/windmill-labs/windmill/issues/11218)) ([f0d66a4](https://github.com/windmill-labs/windmill/commit/f0d66a42eba27502767423af0c08b2ca1f16e8df))
* re-point cloned fork identities that name nobody in the fork ([#11161](https://github.com/windmill-labs/windmill/issues/11161)) ([9690c44](https://github.com/windmill-labs/windmill/commit/9690c4462cf264a5577b87d07d465b5442b4e09d))
* register the job token with the sensitive log masking system ([#10943](https://github.com/windmill-labs/windmill/issues/10943)) ([53afecd](https://github.com/windmill-labs/windmill/commit/53afecd4588247bc1812d3e68a30db1f3c3b2724))
* show the New menu's description panel only on hover ([#11199](https://github.com/windmill-labs/windmill/issues/11199)) ([ecd0a6c](https://github.com/windmill-labs/windmill/commit/ecd0a6c77bc3a057b8072dbb0aca731e8bd3d882))
* stop picker listing a draft twice when drafts share a friendly path ([#11214](https://github.com/windmill-labs/windmill/issues/11214)) ([72507d5](https://github.com/windmill-labs/windmill/commit/72507d52a021de8101794ee8f4a3392be1ba20c7))
### Performance Improvements
* count completed jobs on the v2_job_completed index with a timeout ([#11211](https://github.com/windmill-labs/windmill/issues/11211)) ([d1a2536](https://github.com/windmill-labs/windmill/commit/d1a25360b070994a31fb033707dbfde1fb553939))
## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17)
+4 -8
View File
@@ -49,16 +49,12 @@ export interface BenchmarkWorkspaceFlow {
export interface BenchmarkWorkspaceApp {
path: string
summary: string
/** Defaults to true. Set false for a drag-and-drop app, which the chat can list
* and read but has no tool to edit — its value is a grid, not files. */
rawApp?: boolean
value: {
files?: Record<string, string>
runnables?: Record<string, unknown>
files: Record<string, string>
runnables: Record<string, unknown>
data?: unknown
policy?: unknown
custom_path?: unknown
[key: string]: unknown
}
}
@@ -998,7 +994,7 @@ function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp {
extra_perms: {},
edited_at: BENCHMARK_TIMESTAMP,
execution_mode: 'viewer',
raw_app: app.rawApp ?? true
raw_app: true
}
}
@@ -1016,7 +1012,7 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
execution_mode: 'viewer',
extra_perms: {},
custom_path: app.value.custom_path as string | undefined,
raw_app: app.rawApp ?? true
raw_app: true
}
}
-37
View File
@@ -2611,40 +2611,3 @@
judgeChecklist:
- runs the existing script rather than rewriting it
- passes the GitHub resource as the bare string $res:f/evals/global/github_main
- id: global-drag-and-drop-app-not-editable
prompt: |-
Add a refresh button to the ops console app, and the same to the sales board app.
initial: ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json
runtime:
maxTurns: 12
validate:
# One request, two apps, only one of them editable: the code app must come back with a
# draft and the drag-and-drop one must not. Refusing both, or editing both, fails here —
# which is what makes this a test of the distinction rather than of caution.
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/ops_console
forbiddenDrafts:
- type: app
path: f/evals/global/sales_board
toolExpect:
# Deliberately not constraining write_app_file/patch_app_file by argument: an entry there
# fails when its tool was never called, so naming both would fail on whichever the model
# did not pick. The draft assertions above cover the same ground, tool-agnostically.
forbiddenToolsUsed:
- init_app
- deploy_workspace_item
- delete_app_file
- delete_app_runnable
assistantExpect:
# A refusal leaves no draft for the judge to read, so the explanation is checked here.
# Only the app kind: substring tests cannot see paraphrase, and every wording of "I can't
# edit it" defeats a fixed list.
requiredMentionsAnyOf:
- - drag-and-drop
- drag and drop
- low-code
- no-code
skipJudge: true
@@ -1,30 +0,0 @@
{
"user": {
"username": "admin",
"is_admin": true,
"folders": ["evals"],
"folders_read": ["evals"]
},
"workspace": {
"apps": [
{
"path": "f/evals/global/sales_board",
"summary": "Sales board",
"rawApp": false,
"value": {
"grid": []
}
},
{
"path": "f/evals/global/ops_console",
"summary": "Ops console",
"value": {
"files": {
"/App.tsx": "export default function App() {\n\treturn <div>Ops console</div>\n}\n"
},
"runnables": {}
}
}
]
}
}
@@ -1,43 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft AS d\n SET path = $3::text,\n value = (\n SELECT CASE\n WHEN s.clean -> 'path' = to_jsonb($2::text)\n OR s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n s.clean\n || CASE WHEN s.clean -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE d.value\n END\n FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),\n chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s\n )\n WHERE d.workspace_id = $1\n AND d.path = $2::text\n AND d.typ::text = ANY($4::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934"
}
@@ -1,71 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "base",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
}
]
},
"nullable": [
false,
false,
true
]
},
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
}
@@ -1,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft\n SET path = $3,\n -- Both path keys, not just the typed one: the editors mirror the\n -- typed path into the other while it differs from the row's path,\n -- and the loaders prefer the mirror — left naming the old location\n -- it un-does this move on the next save. `create_missing = false`\n -- on both, so a draft carrying only one keeps only one.\n value = to_json(\n jsonb_set(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n ),\n ARRAY[$8::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])\n -- Of this kind only the caller's own row and the legacy one collide:\n -- teammates' drafts of one item share its path by design, but a deploy\n -- there wipes those two together, so a second would discard edits the\n -- caller never saw. The other app kind is a different item on the same\n -- deployed path, so it collides whoever owns it.\n AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)\n ))\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108"
}
@@ -1,49 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a\n JOIN LATERAL (\n SELECT av2.id, COALESCE(v.ord, 0) AS ord\n FROM app_version av2\n LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id\n WHERE av2.app_id = a.id\n ORDER BY ord DESC, av2.id DESC\n LIMIT $3 OFFSET $4\n ) page ON TRUE\n JOIN app_version av ON av.id = page.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2\n ORDER BY page.ord DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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 = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text\n FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])\n AND m.new_path = $3 AND m.email IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM draft_move o\n WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ\n AND o.old_path = m.old_path AND o.email = $5::text\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET auto_invite = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"TextArray",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET new_path = $4\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"",
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
"describe": {
"columns": [
{
@@ -55,5 +55,5 @@
null
]
},
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft_move src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.old_path = dest.old_path\n AND src.typ = dest.typ)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true\n AND ($2::varchar IS NULL OR workspace_id = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": [
null
]
},
"hash": "75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8"
}
@@ -1,72 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3::text, $4,\n CASE WHEN $10::bool\n THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(\n 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END,\n 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END)))\n ELSE $5::text::json\n END,\n COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = CASE\n WHEN NOT $10::bool\n OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0\n THEN EXCLUDED.value\n ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')\n || jsonb_strip_nulls(jsonb_build_object(\n 'path', to_jsonb(draft.value) -> 'path',\n 'draft_path', to_jsonb(draft.value) -> 'draft_path')))\n END,\n created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING path, created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Timestamptz",
"Bool",
"Timestamptz",
"Text",
"Bool",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Varchar",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14"
}
@@ -1,43 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version\n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2\n ORDER BY flow_version.created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled, usr.is_service_account\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
@@ -52,11 +52,6 @@
"ordinal": 9,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
@@ -74,9 +69,8 @@
true,
false,
null,
false,
false
]
},
"hash": "7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e"
"hash": "88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + make_interval(days => $1)\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
"describe": {
"columns": [
{
@@ -25,9 +25,7 @@
}
],
"parameters": {
"Left": [
"Int4"
]
"Left": []
},
"nullable": [
false,
@@ -36,5 +34,5 @@
true
]
},
"hash": "6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc"
"hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC LIMIT 1",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC",
"describe": {
"columns": [
{
@@ -17,11 +17,6 @@
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
@@ -33,9 +28,8 @@
"nullable": [
false,
true,
false,
false
]
},
"hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb"
"hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET auto_invite = (COALESCE(auto_invite, '{}'::jsonb) - 'domain') || $1::jsonb WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb"
}
@@ -1,85 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n -- The guard's own predicate, ordered own row, then legacy, then another\n -- user's other-kind row: each needs a different remedy, and a bare\n -- LIMIT 1 would name an arbitrary one.\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[])\n AND (typ <> $2 OR email = $4 OR email IS NULL)\n ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email IS NULL) as \"at_target_legacy!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "at_target",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "at_target_own!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "at_target_legacy!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "poisoned!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "legacy!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995"
"hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142"
}
@@ -1,47 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a JOIN app_version av\n ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n ))\n RETURNING token_prefix",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99"
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -177,11 +177,6 @@
"ordinal": 34,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
},
{
"ordinal": 35,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
@@ -224,9 +219,8 @@
false,
false,
true,
true,
false
true
]
},
"hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363"
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b"
}
@@ -1,59 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT m.new_path FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3\n AND (m.email IS NULL OR m.email = $4)\n AND NOT EXISTS (\n SELECT 1 FROM draft d\n WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3\n AND d.email IS NOT DISTINCT FROM $4\n )\n ORDER BY m.email IS NULL\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "new_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -45,21 +45,16 @@
},
{
"ordinal": 8,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"ordinal": 9,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"ordinal": 10,
"name": "datatable",
"type_info": "Jsonb"
}
@@ -78,11 +73,10 @@
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a"
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
}
@@ -1,41 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780"
}
@@ -1,31 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT d.email IS NULL as \"legacy!\", COALESCE(u.username, p.username) as username\n FROM draft d\n LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email\n LEFT JOIN password p ON p.email = d.email AND p.super_admin = true\n WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])\n -- A row a move already brought here from the very path being renamed is this\n -- rename's own draft, not an item in its way: the record says which owners\n -- it moved (all of them for an item move, one for a draft-only move). Any\n -- other row is a second item and still collides.\n AND NOT EXISTS (\n SELECT 1 FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])\n AND m.old_path = $4 AND m.new_path = $2\n AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)\n )\n ORDER BY 2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "legacy!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"TextArray",
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46"
}
+86 -85
View File
@@ -2311,9 +2311,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.7"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -5055,9 +5055,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "fixedbitset"
@@ -5497,9 +5497,9 @@ dependencies = [
[[package]]
name = "generator"
version = "0.8.10"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
@@ -14793,7 +14793,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14881,7 +14881,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14915,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"argon2",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15091,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15108,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15144,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15161,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15183,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15206,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15222,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15244,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15266,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15280,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15315,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15340,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15368,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15390,7 +15390,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15410,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15448,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15477,7 +15477,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"lazy_static",
"serde",
@@ -15489,7 +15489,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15513,7 +15513,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15527,7 +15527,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15562,7 +15562,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15595,7 +15595,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15653,6 +15653,7 @@ dependencies = [
"pin-project-lite",
"pkcs1",
"postgres-native-tls 0.5.3",
"postgres-protocol",
"prometheus",
"quick_cache",
"rand 0.9.0",
@@ -15702,7 +15703,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"chrono",
"futures",
@@ -15722,7 +15723,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"regex",
"serde",
@@ -15739,7 +15740,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15766,7 +15767,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"futures",
@@ -15783,7 +15784,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15799,7 +15800,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15820,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15851,7 +15852,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15876,7 +15877,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15911,7 +15912,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"futures",
@@ -15929,7 +15930,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15938,7 +15939,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15950,7 +15951,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15962,7 +15963,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15974,7 +15975,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15986,7 +15987,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15998,7 +15999,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16009,7 +16010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16020,7 +16021,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16032,7 +16033,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16043,7 +16044,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16065,7 +16066,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16077,7 +16078,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16092,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16108,7 +16109,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16121,7 +16122,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde",
@@ -16133,7 +16134,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16151,7 +16152,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16167,7 +16168,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16183,7 +16184,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16197,7 +16198,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16236,7 +16237,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"const_format",
@@ -16276,7 +16277,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16287,7 +16288,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16322,7 +16323,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16347,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16380,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16406,7 +16407,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16439,7 +16440,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16459,7 +16460,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16493,7 +16494,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16529,7 +16530,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16552,7 +16553,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16576,7 +16577,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16600,7 +16601,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16635,7 +16636,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16663,7 +16664,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16688,7 +16689,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16707,7 +16708,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16825,7 +16826,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"bytes",
"futures",
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.815.0"
version = "1.814.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.815.0"
version = "1.814.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -624,6 +624,7 @@ wasm-bindgen-test = "^0"
convert_case = "0.6.0"
getrandom = "0.2"
tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]}
postgres-protocol = "0.6"
rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"}
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
bit-vec = "=0.6.3"
+1 -1
View File
@@ -1 +1 @@
bc3ef08c8e4233508c023e6ee847a3cd0b8be43b
220301c27272b6c9e38401884c45cd959bd02a09
@@ -1 +0,0 @@
ALTER TABLE draft DROP COLUMN base;
@@ -1,14 +0,0 @@
-- The version a draft forked from, as one opaque text id whatever the kind: a
-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that
-- was never forked from a deploy and for kinds that keep no lineage.
ALTER TABLE draft ADD COLUMN base TEXT;
-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and
-- get their base on their next save.
UPDATE draft SET base = CASE typ::text
WHEN 'script' THEN value ->> 'parent_hash'
WHEN 'flow' THEN value ->> 'version_id'
ELSE value ->> 'parent_version'
END
WHERE typ::text IN ('script', 'flow', 'app', 'raw_app')
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0;
@@ -1 +0,0 @@
DROP TABLE draft_move;
@@ -1,17 +0,0 @@
-- Where an item's drafts went when it moved, so a draft save still addressed to the
-- old path (an editor left open across the move, a chat, the CLI) lands on the moved
-- draft instead of starting a new item there. `email` NULL records a deployed item's
-- move and applies to every user; set, it records that user's draft-only move.
CREATE TABLE draft_move (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
typ DRAFT_KIND NOT NULL,
old_path VARCHAR(255) NOT NULL,
new_path VARCHAR(255) NOT NULL,
email VARCHAR(255)
);
CREATE INDEX draft_move_old_path_idx ON draft_move (workspace_id, typ, old_path);
GRANT ALL ON draft_move TO windmill_user;
GRANT ALL ON draft_move TO windmill_admin;
@@ -1 +0,0 @@
ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks;
@@ -1 +0,0 @@
ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.815.0"
version = "1.814.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.815.0"
version = "1.814.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+2 -7
View File
@@ -52,8 +52,7 @@ use windmill_common::{
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NO_DEFAULT_MAVEN_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
@@ -127,8 +126,7 @@ use windmill_worker::{
use crate::monitor::{
initial_load, load_concurrency_key_max_queued, load_disable_password_login,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
load_mcp_disable_token_query_param, load_metrics_debug_enabled,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled,
load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides,
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces,
load_workspace_fairness_duration_secs, load_workspace_fairness_enabled,
@@ -2166,9 +2164,6 @@ async fn process_notify_event(
DISABLE_PASSWORD_LOGIN_SETTING => {
load_disable_password_login(db).await;
}
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
load_mcp_disable_token_query_param(db).await;
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
+3 -34
View File
@@ -45,10 +45,7 @@ use windmill_common::otel_oss::{
use windmill_common::{
agent_workers::DECODED_AGENT_TOKEN,
apps::APP_WORKSPACED_ROUTE,
auth::{
create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs,
TOKEN_EXPIRY_WARNING_DAYS,
},
auth::{create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs},
ee_oss::CriticalErrorChannel,
email_oss::send_email_if_possible,
error,
@@ -65,7 +62,6 @@ use windmill_common::{
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
@@ -289,15 +285,6 @@ pub async fn initial_load(
);
if let Some(db) = conn.as_sql() {
// Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes
// with `server_mode` false and would otherwise never read this at all. That mode
// joins no monitor loop, so there — as for every global setting, `base_url`
// included — this pass is the only read, and a change lands on restart.
pass.setting(
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
false,
|v| async move { apply_mcp_disable_token_query_param(v) },
);
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
apply_tag_per_workspace_enabled(v)
});
@@ -1627,23 +1614,6 @@ pub fn apply_disable_password_login(value: Option<serde_json::Value>) {
};
}
pub async fn load_mcp_disable_token_query_param(db: &DB) {
match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await {
Ok(v) => apply_mcp_disable_token_query_param(v),
Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"),
};
}
pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
match value {
Some(serde_json::Value::Bool(t)) => {
MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed)
}
None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed),
_ => (),
};
}
struct LogFile {
file_path: String,
hostname: String,
@@ -2228,7 +2198,7 @@ async fn cleanup_scheduled_job_deletions(db: &Pool<Postgres>) {
}
pub async fn check_expiring_tokens(db: &DB) {
// Find tokens expiring within the warning window that still have a pending notification row.
// Find tokens expiring within 7 days that still have a pending notification row.
// The notification table stores token_hash (not plaintext) so the join works
// even after the hash migration makes token.token nullable.
let expiring_tokens_r = sqlx::query_as!(
@@ -2237,9 +2207,8 @@ pub async fn check_expiring_tokens(db: &DB) {
USING token t
WHERE n.token_hash = t.token_hash
AND n.expiration > now()
AND n.expiration <= now() + make_interval(days => $1)
AND n.expiration <= now() + interval '7 days'
RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
TOKEN_EXPIRY_WARNING_DAYS,
)
.fetch_all(db)
.await;
+1 -1
View File
@@ -234,7 +234,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
-193
View File
@@ -1,193 +0,0 @@
//! The deployed order of an app's versions is the order they were appended to
//! `app.versions`, not the order of their `created_at`.
//!
//! `app_version.created_at` defaults to `now()`, which in Postgres is the
//! transaction's start time, while the append happens under the app row's lock.
//! Two deploys that overlap therefore land in one order and carry timestamps in
//! the other. The head the editor guards against, and the sequence the diff
//! picker numbers, both have to follow the array.
//!
//! Users from the `base` fixture: test-user (admin, token SECRET_TOKEN).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const TOKEN: &str = "SECRET_TOKEN";
#[sqlx::test(fixtures("base"))]
async fn test_app_head_follows_the_append_order_not_the_timestamps(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let ws = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let client = reqwest::Client::new();
let res = client
.post(format!("{ws}/apps/create"))
.header("Authorization", format!("Bearer {TOKEN}"))
.json(&json!({
"path": "u/test-user/order_app",
"summary": "ordered",
"value": {},
"policy": { "execution_mode": "publisher", "triggerables": {} }
}))
.send()
.await?;
assert!(res.status().is_success(), "{}", res.text().await?);
let first: i64 = sqlx::query_scalar(
"SELECT versions[array_upper(versions, 1)] FROM app
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.fetch_one(&db)
.await?;
// The overlapping deploy: appended after `first`, so it is the version that
// landed, but stamped before it, so a timestamp sort puts it underneath.
let second: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'racer', created_at - interval '1 hour', raw_app
FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = array_append(versions, $1::bigint)
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.bind(second)
.execute(&db)
.await?;
let head: serde_json::Value = client
.get(format!(
"{ws}/apps/get_latest_version/u/test-user/order_app"
))
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
assert_eq!(
head["version"], second,
"the head is the version appended last, not the newest timestamp: {head}"
);
let history: Vec<serde_json::Value> = client
.get(format!("{ws}/apps/history/p/u/test-user/order_app"))
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
let listed: Vec<i64> = history
.iter()
.map(|v| v["version"].as_i64().unwrap())
.collect();
assert_eq!(
listed,
vec![second, first],
"the history lists in deployed order, so it leads with the head"
);
// Enough versions that a page cannot hold them, so "asked for nothing" and "asked for
// a page" are visibly different answers.
let mut appended = vec![second, first];
for _ in 0..24 {
let extra: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'bulk', created_at, raw_app FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = array_append(versions, $1::bigint)
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.bind(extra)
.execute(&db)
.await?;
appended.insert(0, extra);
}
let versions_at = |query: &str| {
let url = format!("{ws}/apps/history/p/u/test-user/order_app{query}");
let client = client.clone();
async move {
let rows: Vec<serde_json::Value> = client
.get(url)
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
Ok::<_, anyhow::Error>(
rows.iter()
.map(|v| v["version"].as_i64().unwrap())
.collect::<Vec<_>>(),
)
}
};
// The deployment-history panel and the CLI read this endpoint without paging, so
// asking for no page has to keep answering with the whole history.
assert_eq!(
versions_at("").await?,
appended,
"an unpaginated request still answers whole"
);
assert_eq!(
versions_at("?per_page=10").await?,
appended[..10],
"a page holds what was asked for, newest first"
);
assert_eq!(
versions_at("?per_page=10&page=2").await?,
appended[10..20],
"the next page carries on where the first left off, skipping nothing"
);
// A page past the end runs off it rather than overflowing into one. (The clamp on an
// asked-for size is pinned where it lives, in `paginate_optional`'s own test.)
assert!(
versions_at("?per_page=10&page=99999999")
.await?
.is_empty(),
"a page past the end is empty"
);
// Naming only the page still asks for a page, rather than an offset into a listing
// sized to hold everything — which would answer nothing at all.
assert_eq!(
versions_at("?page=1").await?,
appended,
"a first page with no size named holds the history"
);
// A version that never entered the deployed sequence — one a restore or a fork copy
// could leave behind — still has to be reachable, after the ones that did.
let stray: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'stray', created_at, raw_app FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
let mut with_stray = appended.clone();
with_stray.push(stray);
assert_eq!(
versions_at("").await?,
with_stray,
"a version outside app.versions is listed, and after the deployed sequence"
);
Ok(())
}
+4 -2
View File
@@ -521,8 +521,10 @@ def main():
.await
.unwrap();
let deployed: serde_json::Value = res.json().await.unwrap();
assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}");
assert_eq!(
res.text().await.unwrap(),
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
);
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
-74
View File
@@ -1,74 +0,0 @@
//! Moving a draft must carry both of its path keys.
//!
//! A draft value holds a typed path and a mirror the editors keep beside it while
//! it differs from the row's path (`path`/`draft_path`; which is which depends on
//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed
//! key left the mirror naming the old location: reopening the item restored the
//! old path, and the next autosave wrote it back — undoing the move silently.
//!
//! This pins `move_draft`, including that a draft with no mirror never gains one.
//! A deploy's carry follows the keys only where they still name the old path
//! (`drafts_save_follows_move` pins that).
use serde_json::Value;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result<String> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&serde_json::json!({ "new_path": to }))
.send()
.await?;
// Asserted here so a rejected move fails as itself, rather than as a JSON
// parse error in the read-back below.
let status = resp.status();
let body = resp.text().await?;
assert!(status.is_success(), "move {from} -> {to} failed: {body}");
Ok(body)
}
/// The stored draft value at `path`, read back through the API so this test needs
/// no `sqlx::query!` (which would want an offline cache entry of its own).
async fn value_at(port: u16, path: &str) -> anyhow::Result<Value> {
let body: Value = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(body
.get("value")
.cloned()
.unwrap_or_else(|| panic!("no draft at {path}: {body}")))
}
#[sqlx::test(fixtures("base", "drafts_move_mirror"))]
async fn test_move_carries_both_path_keys(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?;
move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?;
// The mirror follows: left at `u/test-user/friendly` it would win at load and
// walk the item back there.
let moved = value_at(port, "u/test-user/renamed").await?;
assert_eq!(moved["path"], "u/test-user/renamed");
assert_eq!(moved["draft_path"], "u/test-user/renamed");
// A draft that never had a mirror must not be given one.
let plain = value_at(port, "u/test-user/plain2").await?;
assert_eq!(plain["path"], "u/test-user/plain2");
assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}");
Ok(())
}
-250
View File
@@ -1,250 +0,0 @@
//! A rename onto a path that already holds a draft is refused.
//!
//! Nothing deployed can sit at a rename's destination (the deploy conflicts on
//! that), but a draft can: a never-deployed item, or a draft left on an archived
//! script. Moving onto it would merge two items or strand a row, so the rename
//! itself fails, in its own transaction, and the source stays deployed. The
//! destination draft here is the deployer's own, which is the same collision.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Hex form of script hash 7010, the way the API takes a parent hash.
const HEAD_HASH: &str = "0000000000001b62";
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_rename_onto_a_draft_is_refused(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/mvtaken_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "rename onto a draft was not refused: {body}");
assert!(
body.contains("already has a draft"),
"unexpected refusal: {body}"
);
// The whole deploy rolled back: the source is still the live head, and the
// draft at the destination is untouched.
let head: Value = client
.get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/p/u/test-user/mvtaken_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
assert_eq!(head["hash"], HEAD_HASH, "source was replaced: {head}");
assert_eq!(head["archived"], false, "source was archived: {head}");
let draft: Value = client
.get(format!("http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/mvtaken_b"))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
assert_eq!(
draft["value"]["summary"], "B",
"destination draft changed: {draft}"
);
Ok(())
}
/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together
/// with the caller's own row, so a move that parks a second draft beside it would discard
/// edits the caller never saw. Only an admin can clear it, so the refusal says so.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_a_legacy_destination(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_legacy" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}");
assert!(
body.contains("legacy workspace draft") && body.contains("workspace admin"),
"the refusal did not point at the one remedy: {body}"
);
// Both rows stayed where they were: the caller's own, and the legacy one the list
// synthesizes under the caller's name.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy"))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("script", "u/test-user/mvtaken_b"),
("script", "u/test-user/mvtaken_legacy")
],
"{list:?}"
);
Ok(())
}
/// A classic app and a raw app deploy into the same table, so a draft-only move onto
/// the other kind's draft must be refused: deploying either path afterwards deletes
/// the caller's drafts of both kinds, taking the loser's item with it.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_the_other_app_kind(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/raw_app/u/test-user/mvtaken_raw"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_app" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"move onto a classic app draft was allowed: {body}"
);
assert!(
body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"),
"the refusal did not name the occupying kind: {body}"
);
// Both drafts are untouched.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter(|d| matches!(d["kind"].as_str(), Some("app") | Some("raw_app")))
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("app", "u/test-user/mvtaken_app"),
("raw_app", "u/test-user/mvtaken_raw")
],
"{list:?}"
);
Ok(())
}
/// Teammates' drafts of one item share its path by design, so another user's row is no
/// obstacle — except across the app pair, where the two kinds are different items on one
/// deployed path: deploying either strands the other, and deleting the app takes both.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_another_users_other_app_kind(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let move_to = |kind: &'static str, from: &'static str, to: &'static str| async move {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/{kind}/{from}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": to }))
.send()
.await?;
Ok::<_, anyhow::Error>((resp.status(), resp.text().await?))
};
let (status, body) = move_to(
"app",
"u/test-user/mvtaken_app",
"u/test-user/mvtaken_theirs",
)
.await?;
assert_eq!(
status, 400,
"a classic app was moved onto another user's raw app: {body}"
);
assert!(
body.contains("holds another user's raw app draft"),
"the refusal did not name the occupant: {body}"
);
// And the other direction, where the occupant reads as the classic kind.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_app_theirs",
)
.await?;
assert_eq!(
status, 400,
"a raw app was moved onto another user's classic app: {body}"
);
assert!(
body.contains("holds another user's app draft"),
"the refusal did not name the occupant: {body}"
);
// The same-kind case is the ordinary one: two users' drafts of one raw app.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_theirs",
)
.await?;
assert!(
status.is_success(),
"a raw app was refused beside another user's raw-app draft: {body}"
);
Ok(())
}
-43
View File
@@ -78,46 +78,3 @@ async fn test_draft_write_strips_nul(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// The lineage the server derives for `draft.base` comes from the same value: read before
/// the sanitizer, a NUL in it costs the draft its base, which then reads as up to date
/// whatever the head is.
#[sqlx::test(fixtures("drafts_nul"))]
async fn test_draft_base_is_derived_from_sanitized_value(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/dnul-ws");
let resp = authed(client().post(format!(
"{base}/drafts/update/script/u/dnul-admin/poisoned_base"
)))
.json(&json!({
"value": {
"summary": "s",
"path": "u/dnul-admin/poisoned_base",
"content": "x",
"parent_hash": "0000000000001b62\u{0}"
}
}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"save refused: {}",
resp.text().await.unwrap_or_default()
);
let stored: Option<String> = sqlx::query_scalar(
"SELECT base FROM draft WHERE workspace_id = 'dnul-ws' AND path = 'u/dnul-admin/poisoned_base'",
)
.fetch_one(&db)
.await?;
assert_eq!(
stored.as_deref(),
Some("0000000000001b62"),
"the NUL cost the draft its base: {stored:?}"
);
Ok(())
}
-587
View File
@@ -1,587 +0,0 @@
//! A draft save addressed to a path its item moved away from lands on the moved draft.
//!
//! A move carries every draft on the item to the new path and records where they
//! went. An editor left open across it still saves to the old path; the server puts
//! the save on the moved draft, keeps the path keys the move gave it, and names the
//! new path so the editor can follow. Without the record the save would plant a
//! phantom draft-only item at the old location.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Hex form of script hash 7030, the way the API takes a parent hash.
const HEAD_HASH: &str = "0000000000001b76";
async fn own_draft_value(port: u16, path: &str) -> anyhow::Result<Value> {
let draft: Value = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(draft["value"].clone())
}
async fn own_draft_paths(port: u16) -> anyhow::Result<Vec<String>> {
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(list
.iter()
.filter(|d| d["kind"] == "script")
.filter_map(|d| d["path"].as_str().map(String::from))
.filter(|p| p.starts_with("u/test-user/follow_"))
.collect())
}
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_save_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
// Rename from Home: a redeploy of the deployed content at the new path that
// keeps the deployer's own draft, so the draft is carried rather than consumed.
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {},
"skip_draft_deletion": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
// A script draft's `path` is where deploying it lands, so it moves with the row.
assert_eq!(
own_draft_value(port, "u/test-user/follow_b").await?["path"],
"u/test-user/follow_b"
);
// The editor is still on the old path and writes that path back into the value.
let saved: Value = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": {
"path": "u/test-user/follow_a",
"parent_hash": HEAD_HASH,
"summary": "A",
"content": "edited after the move"
}
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["status"], "saved", "save refused: {saved}");
assert_eq!(
saved["path"], "u/test-user/follow_b",
"save did not follow the row: {saved}"
);
// The write landed on the carried row; nothing reappeared at the old path.
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
let draft = own_draft_value(port, "u/test-user/follow_b").await?;
assert_eq!(draft["content"], "edited after the move", "{draft}");
assert_eq!(draft["path"], "u/test-user/follow_b", "{draft}");
Ok(())
}
/// A draft-only move rewrites both path keys. The owner's open editor still carries
/// the typed path it had, which names neither the old nor the new path; the moved
/// draft's own keys have to win, or the save walks the item back.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_save_follows_a_draft_only_move(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/moved" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
let saved: Value = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": {
"path": "u/test-user/friendly",
"draft_path": "u/test-user/friendly",
"summary": "D",
"content": "edited after the move"
}
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["path"], "u/test-user/moved", "{saved}");
let draft = own_draft_value(port, "u/test-user/moved").await?;
assert_eq!(draft["content"], "edited after the move", "{draft}");
assert_eq!(draft["path"], "u/test-user/moved", "{draft}");
assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}");
Ok(())
}
/// Rename `from` to `to` the way Home does: redeploy the deployed content at the new
/// path, keeping the deployer's own draft so it is carried rather than consumed.
/// Returns the new head's hash.
async fn rename(port: u16, from_hash: &str, to: &str) -> anyhow::Result<String> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": to,
"parent_hash": from_hash,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {},
"skip_draft_deletion": true
}))
.send()
.await?;
let status = resp.status();
let hash = resp.text().await?;
assert_eq!(status, 201, "rename to {to} failed: {hash}");
Ok(hash)
}
/// Save the draft as an editor still bound to `url_path` would. Returns the path the
/// save landed at.
async fn save_at(port: u16, url_path: &str, content: &str) -> anyhow::Result<String> {
let saved: Value = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/{url_path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": { "path": url_path, "summary": "A", "content": content, "language": "deno" }
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["status"], "saved", "save refused: {saved}");
Ok(saved["path"].as_str().unwrap_or_default().to_string())
}
/// A record is kept to one hop, and a move back to the path it left ends it: both are
/// three statements whose order decides the answer.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_move_records_stay_one_hop(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let _c = rename(port, &b, "u/test-user/follow_c").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "after two moves").await?,
"u/test-user/follow_c",
"a save at the first path did not reach the last"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
Ok(())
}
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_move_back_ends_the_record(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let _a = rename(port, &b, "u/test-user/follow_a").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "after moving back").await?,
"u/test-user/follow_a",
"a save was routed off the path the item moved back to"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_a"]);
Ok(())
}
/// A rename carries a teammate's row too: both its path keys follow, and the version
/// it forked from does not move. A restamp there would clear their out-of-date prompt
/// and let them deploy over the mover's version believing they were current.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_teammates_draft_follows_with_its_base(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
// Read from the pool: the teammate's row is another user's, and this asserts on
// `base`, which no endpoint exposes for someone else's draft.
let row: (String, String, Option<String>) = sqlx::query_as(
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', base
FROM draft WHERE workspace_id = 'test-workspace' AND typ = 'script'
AND email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
assert_eq!(
row.2.as_deref(),
Some(HEAD_HASH),
"the teammate's base was restamped by someone else's rename"
);
Ok(())
}
/// An item move and then the owner's own move of what is left: the two records have
/// different scopes, so the owner's move has to extend the chain in its own scope or
/// a save addressed to the first path stops at the abandoned middle one.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_an_owner_move_extends_an_item_move(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
// Archiving the script at the new path leaves the carried draft as a draft-only
// item, which its owner can move through `/drafts/move`.
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_b'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_c" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
assert_eq!(
save_at(port, "u/test-user/follow_a", "after both moves").await?,
"u/test-user/follow_c",
"a save at the first path stopped at the path the owner's move left"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
Ok(())
}
/// Redeploying at a path an owner's move routed away from ends that route: the live item
/// owns its path again, and its saves must not follow the draft that left.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// `move_draft` ignores archived rows, so an archived script's draft can be moved away.
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Unarchiving redeploys at the same path, with the archived version as parent.
rename(port, HEAD_HASH, "u/test-user/follow_a").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "for the live script").await?,
"u/test-user/follow_a",
"a save for the redeployed script followed the draft that moved away"
);
Ok(())
}
/// A draft written before the NUL sanitizer still has to follow a move: its path keys are
/// what a deploy of it would land on, so the carry rewrites them, sanitizing the value it
/// could not otherwise parse.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_poisoned_draft_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The teammate's row, rewritten the way a pre-sanitizer client left one: a real NUL
// escape in the content, both path keys naming the path the item is about to leave.
sqlx::query(
r#"UPDATE draft SET value = '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a",
"parent_hash": "0000000000001b76", "summary": "A", "content": "a\u0000b"}'
WHERE email = 'test2@windmill.dev'"#,
)
.execute(&db)
.await?;
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let row: (String, String, String) = sqlx::query_as(
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', value::jsonb ->> 'content'
FROM draft WHERE email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
assert_eq!(row.2, "ab", "the NUL survived the rewrite");
Ok(())
}
/// A discard whose answer is lost is retried, and the retry finds nothing to delete. It
/// still has to name where the item went: the editor asking is on the path it left, and a
/// reload there lands on nothing.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_retried_discard_still_names_the_destination(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let discard = |token: &'static str| {
let client = client.clone();
async move {
client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "value": null }))
.send()
.await?
.json::<Value>()
.await
}
};
let first = discard("SECRET_TOKEN").await?;
assert_eq!(first["status"], "saved", "{first}");
assert_eq!(first["path"], "u/test-user/follow_b", "{first}");
let retry = discard("SECRET_TOKEN").await?;
assert_eq!(
retry["status"], "saved",
"the retry was not idempotent: {retry}"
);
assert_eq!(retry["path"], "u/test-user/follow_b", "{retry}");
// A third user has no draft on this item, so their discard deletes nothing and the
// destination is a move record and nothing else: it is answered only to a caller who
// can read that path.
let other = discard("SECRET_TOKEN_3").await?;
assert_eq!(other["status"], "saved", "{other}");
assert_eq!(
other["path"],
Value::Null,
"a user who cannot read the destination was told where the item went: {other}"
);
Ok(())
}
/// The legacy workspace-level row is carried by a rename like any other draft, and the
/// record that routes saves to it covers every caller — so discarding it from a page that
/// still names the old path has to reach it where it went.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_legacy_discard_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query(
r#"INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "summary": "legacy", "content": "x"}', NULL)"#,
)
.execute(&db)
.await?;
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "value": null, "legacy": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"discard failed: {}",
resp.text().await?
);
let left: i64 = sqlx::query_scalar(
"SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND email IS NULL",
)
.fetch_one(&db)
.await?;
assert_eq!(
left, 0,
"the legacy draft survived a discard aimed at its old path"
);
Ok(())
}
/// Deploying a draft that a move carried off an archived script: its parent is still the
/// version at the old path, so the deploy renames from there and carries what is left over
/// — onto the very draft being deployed. That row is this deploy's own, not an item in its
/// way, or the deploy is refused and every retry refuses again.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_moved_draft_deploys_at_its_new_path(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Deploy it where it now lives, still parented on the archived version it forked from.
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 2 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 201, "the moved draft could not be deployed: {body}");
Ok(())
}
/// A route is only as good as the item it points at: when an unrelated item claims the
/// destination, a save still addressed to the old path must stay where it is rather than
/// land on that item's draft.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_reused_destination_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Someone else's item takes the destination, and the moved draft goes with the
// deploy that consumes it.
sqlx::query("DELETE FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/follow_b'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"summary": "unrelated",
"description": "",
"content": "export function main() { return 3 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create failed: {}", resp.text().await?);
assert_eq!(
save_at(port, "u/test-user/follow_a", "after the destination was reused").await?,
"u/test-user/follow_a",
"a save was routed onto the item that now owns the destination"
);
Ok(())
}
-16
View File
@@ -1,16 +0,0 @@
-- Fixture for the two-path-key move test.
--
-- Two draft-only scripts owned by test-user, both parked at a generated storage
-- key the way a session-created draft is. One carries the `draft_path` mirror the
-- session editor writes while the typed path differs from that key; the other
-- carries no mirror at all, which is what a plain draft looks like.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/draft_mirror', 'script',
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly",
"content": "x", "language": "bun", "summary": "S"}',
'test@windmill.dev'),
('test-workspace', 'u/test-user/draft_plain', 'script',
'{"path": "u/test-user/draft_plain",
"content": "x", "language": "bun", "summary": "S"}',
'test@windmill.dev');
-37
View File
@@ -1,37 +0,0 @@
-- Fixture for refusing a rename onto a path a draft already occupies.
--
-- A deployed script at `u/test-user/mvtaken_a` (hash 7010 = 0x1b62), and a
-- never-deployed draft of test-user's own at `u/test-user/mvtaken_b`, the path
-- the rename will target. Nothing deployed lives at the target, so only the
-- draft can refuse the move.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
schema, summary, description, lock, extra_perms)
VALUES ('test-workspace', 7010, 'u/test-user/mvtaken_a',
'export function main() { return 1 }',
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script',
'{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}',
'test@windmill.dev');
-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it
-- together with the caller's, so it occupies the path just as an own draft does.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script',
'{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}',
NULL);
-- A draft-only classic app and a draft-only raw app of the same owner. They share
-- the `app` table, so one occupies the other's path. The third is another user's raw
-- app: a different item at its own path, not a second copy of anyone's.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/mvtaken_app', 'app',
'{"summary": "classic", "value": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app',
'{"summary": "raw", "files": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app',
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app',
'{"summary": "theirs", "value": {}}', 'test2@windmill.dev');
-29
View File
@@ -1,29 +0,0 @@
-- Fixture for saving a draft to the path its item moved away from.
--
-- A deployed script at `u/test-user/follow_a` (hash 7030 = 0x1b76) with the
-- deployer's own draft on it. The test renames the script and then saves the
-- draft from an editor still bound to the old path.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
schema, summary, description, lock, extra_perms)
VALUES ('test-workspace', 7030, 'u/test-user/follow_a',
'export function main() { return 1 }',
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
INSERT INTO draft (workspace_id, path, typ, value, email, base)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}',
'test@windmill.dev', '0000000000001b76');
-- A draft-only script parked at a generated storage key, its typed path elsewhere.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/draft_store', 'script',
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}',
'test@windmill.dev');
-- A teammate's draft on the same deployed script, forked from the same head. The
-- rename must carry it too, without touching the version it forked from.
INSERT INTO draft (workspace_id, path, typ, value, email, base)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "teammate draft"}',
'test2@windmill.dev', '0000000000001b76');
-57
View File
@@ -3312,60 +3312,3 @@ export function main(i: number) {
Ok(())
}
// A `$flow_expr[...]` step tag is resolved from the flow's state before the step is pushed, and
// one that cannot be resolved fails the step instead of queueing it on a tag no worker serves.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_flow_expr_step_tag(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let step = |id: &str, tag: Option<&str>| {
flow_module(
id,
FlowModuleValue::RawScript {
input_transforms: Default::default(),
language: ScriptLang::Deno,
content: "export function main() { return { lang: 'bun' } }".to_string(),
path: None,
lock: None,
tag: tag.map(str::to_string),
concurrency_settings: Default::default(),
is_trigger: None,
assets: None,
},
)
};
let flow = FlowValue {
modules: vec![
step("a", None),
step("b", Some("$flow_expr[results.a.lang]")),
step("c", Some("nobody-serves-$flow_expr[a.lang]")),
],
same_worker: false,
..Default::default()
};
let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
.run_until_complete(&db, false, server.addr.port())
.await;
let b_tag = sqlx::query_scalar::<_, String>(
"SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'",
)
.bind(job.id)
.fetch_one(&db)
.await?;
assert_eq!(b_tag, "bun");
assert!(!job.success);
let result = job.json_result().unwrap();
let message = result["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("Could not resolve the step tag `nobody-serves-$flow_expr[a.lang]`"),
"got {result:?}"
);
Ok(())
}
-25
View File
@@ -1530,31 +1530,6 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>
);
}
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool<Postgres>) {
clear_settings_and_configs(&db).await;
let before = count_global_settings(&db).await;
let mut desired = BTreeMap::new();
desired.insert(
"max_token_expiration_days".to_string(),
serde_json::json!("7.0"),
);
let err = windmill_common::instance_config::sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired,
)
.await
.expect_err("a ceiling the token routes cannot read must fail the sync");
assert!(
err.to_string().contains("max_token_expiration_days"),
"the error should name the offending setting, got: {err}"
);
assert_eq!(count_global_settings(&db).await, before);
}
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<Postgres>) {
// The declarative writers (the sync-config CLI, the operator's ConfigMap
-108
View File
@@ -1,108 +0,0 @@
/*
* The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted
* for, and job logs are persisted to `job_logs` and optionally to object storage,
* so a script that echoes the token would otherwise park a live credential in
* durable storage. `run_worker` registers the token with `sensitive_log_masks`
* for the job it pulled; this pins that the persisted log carries the masked form.
*/
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_test_utils::*;
/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header.
/// The masked form keeps only `jwt` + the last three characters, so it never matches.
const RAW_TOKEN_PREFIX: &str = "jwt_ey";
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_persisted_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "echo \"running with --token $WM_TOKEN\"".to_string(),
path: None,
lock: None,
language: ScriptLang::Bash,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
/// nativets runs V8 in-process and persists `console.log` output through its own
/// channel, so it is masked by a different mechanism than the bash case above and
/// needs its own guard.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_nativets_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(),
path: None,
lock: None,
language: ScriptLang::Nativets,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
-4
View File
@@ -1249,10 +1249,6 @@ pub async fn register_token_expiry_notification(
if !windmill_common::auth::is_user_token(label) {
return;
}
let warning_days = windmill_common::auth::TOKEN_EXPIRY_WARNING_DAYS;
if expiration <= chrono::Utc::now() + chrono::Duration::days(warning_days.into()) {
return;
}
if let Err(e) = sqlx::query!(
"INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
token_hash,
+6 -42
View File
@@ -51,10 +51,7 @@ use windmill_common::{
jobs::JobPayload,
schedule::Schedule,
triggers::MovedNativeTrigger,
utils::{
http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination,
RunnableKind, StripPath,
},
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -750,14 +747,6 @@ async fn create_flow(
.execute(&mut *tx)
.await?;
}
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
&nf.path,
None,
)
.await?;
audit_log(
&mut *tx,
@@ -926,36 +915,25 @@ pub struct FlowVersion {
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
/// Who deployed this version — the diff's version picker names them so a reader
/// can tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
}
async fn get_flow_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FlowVersion>> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
// Unasked-for, this listing stays whole: the history panels, the restart picker and
// the CLI all read it without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
let flows = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC
LIMIT $3 OFFSET $4",
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
path,
w_id,
per_page,
offset,
w_id
)
.fetch_all(&mut *tx)
.await?;
@@ -975,7 +953,7 @@ async fn get_latest_version(
let version = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
@@ -1460,20 +1438,6 @@ async fn update_flow(
.await?;
}
if is_new_path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
flow_path,
&nf.path,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
@@ -0,0 +1,108 @@
//! Who may read and change a data table's grants and owners. On the Enterprise Edition: its
//! administrators, from the workspace that governs it. Without it: nobody. Each refusal is decided
//! before anything connects to the data table, so the fixture's database never has to exist.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn grant_select_on_public() -> Value {
json!({
"target": {"kind": "schema", "schema": "public"},
"change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"],
"scope": "all_tables"},
"statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#]
})
}
async fn post_acl(
port: u16,
w_id: &str,
action: &str,
token: &str,
) -> anyhow::Result<reqwest::Response> {
Ok(reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&grant_select_on_public())
.send()
.await?)
}
/// A fork reaches the data table through a pointer: it may use it, never change what each role may
/// touch on it — not even as an admin of the fork.
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_fork_cannot_change_access_on_the_data_table_it_points_at(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for action in ["plan", "apply"] {
let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?;
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
}
Ok(())
}
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_member_who_is_not_an_admin_cannot_change_access(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for action in ["plan", "apply"] {
let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?;
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
}
Ok(())
}
/// Not even reading, and not even on a data table that is not under roles — which any member
/// reaches, so only the edition stands between them and the instance's credentials.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn only_the_enterprise_edition_has_the_access_editor(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
"UPDATE workspace_settings
SET datatable = datatable #- '{datatables,main,permissions}'
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let read = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/datatable_acl/main?kind=database"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?;
let mut responses = vec![("read", read)];
for action in ["plan", "apply"] {
responses.push((
action,
post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?,
));
}
for (action, resp) in responses {
assert_eq!(resp.status(), 400, "{action}");
let body = resp.text().await?;
assert!(
body.contains("Data table roles are a Windmill Enterprise Edition feature"),
"{action}: {body}"
);
}
Ok(())
}
@@ -912,6 +912,18 @@ async fn a_stored_name_containing_a_question_mark_resolves_as_itself(
resolve("main?dt").await.is_err(),
"an unknown parameter was ignored"
);
sqlx::query(
"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, '{datatables,main?role=analytics}', datatable->'datatables'->'main')
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
assert!(
resolve("main?role=analytics").await.is_err(),
"a reference naming both a stored data table and a role on another resolved to one of them"
);
Ok(())
}
@@ -1009,6 +1021,70 @@ async fn an_entry_without_roles_cannot_newly_reach_a_database_under_roles(
Ok(())
}
/// Browsing names the role it connects as, and a role the caller may not use is refused rather
/// than quietly listed as the default. The refusal is decided before connecting, so the fixture's
/// database never has to exist.
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn browsing_as_a_role_the_caller_may_not_use_is_refused(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// `test-user-2` is a tenant of `analytics` only.
let resp = authed(
client().get(format!(
"{base}/list_datatable_tables?role_for=main&role=admin"
)),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
let entry = body
.as_array()
.and_then(|a| a.iter().find(|e| e["datatable_name"] == "main"))
.expect("main is listed");
assert_eq!(entry["usable_roles"], json!(["analytics"]), "{entry}");
assert_eq!(entry["default_role"], "analytics", "{entry}");
assert_eq!(entry["permissioned"], true, "{entry}");
assert_eq!(entry["instance"], true, "{entry}");
let error = entry["error"].as_str().unwrap_or_default();
assert!(
error.contains("Not allowed to use role 'admin'"),
"listed as another role than the one asked for: {entry}"
);
let resp = authed(
client().get(format!(
"{base}/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=admin"
)),
"SECRET_TOKEN_2",
)
.send()
.await?;
let status = resp.status();
let text = resp.text().await?;
assert!(
text.contains("Not allowed to use role 'admin'"),
"{status}: {text}"
);
// A role means nothing without the data table it belongs to.
let resp = authed(
client().get(format!("{base}/list_datatable_tables?role=analytics")),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn an_alias_saved_elsewhere_waits_for_roles_going_on_for_its_database(
db: Pool<Postgres>,
@@ -311,130 +311,3 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool<Postgres>) -> any
Ok(())
}
/// Apps, schedules, triggers and their drafts cannot drop an identity the way scripts and flows
/// do, so one naming nobody in the fork goes to its creator while one that still resolves stays.
/// Forked as an admin, whose app policies the clone otherwise keeps.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}/api", server.addr.port());
let stranger = json!({
"on_behalf_of": "u/test-user-2",
"on_behalf_of_email": "test2@windmill.dev",
"execution_mode": "publisher",
});
sqlx::query(
"INSERT INTO app (workspace_id, path, summary, policy, versions)
VALUES ('test-workspace', 'u/test-user/stranger', '', $1, '{}'),
('test-workspace', 'u/test-user/group', '', $2, '{}')",
)
.bind(&stranger)
.bind(json!({
"on_behalf_of": "g/all",
"on_behalf_of_email": "group-all@windmill.dev",
"execution_mode": "publisher",
}))
.execute(&db)
.await?;
// The clone re-aggregates `versions` from `app_version`, and the column is NOT NULL.
sqlx::query(
"WITH v AS (
INSERT INTO app_version (app_id, value, created_by)
SELECT id, '{}'::json, 'test-user' FROM app WHERE workspace_id = 'test-workspace'
RETURNING id, app_id
)
UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = v.app_id",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
VALUES ('test-workspace', 'u/test-user/stranger', 'raw_app', $1::json, NOW(), 'test@windmill.dev'),
('test-workspace', 'u/test-user/stranger', 'trigger_websocket', $2::json, NOW(), 'test@windmill.dev'),
('test-workspace', 'u/test-user/nul', 'raw_app', $3::json, NOW(), 'test@windmill.dev')",
)
.bind(json!({ "policy": stranger }))
.bind(json!({ "permissioned_as": "u/test-user-2" }))
// Saved before drafts were stripped of NULs: any jsonb parse of it raises, so it must be
// skipped rather than abort the fork. Built from parts because a NUL escape can't sit in source.
.bind(format!(
r#"{{"policy":{{"on_behalf_of":"u/test-user-2"}},"files":{{"f":"a{}u0000"}}}}"#,
"\\"
))
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, email, permissioned_as, enabled)
VALUES ('test-workspace', 'u/test-user/stranger', 'test-user', '0 0 * * * *', 'u/test-user/s', 'test2@windmill.dev', 'u/test-user-2', false)",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, edited_by, permissioned_as, mode)
VALUES ('test-workspace', 'u/test-user/stranger', 'ws://localhost', 'u/test-user/s', false, 'test-user', 'u/test-user-2', 'disabled')",
)
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "id": "wm-fork-repoint", "name": "Fork", "color": "#0000ff" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"creating the fork: {}",
resp.text().await?
);
let text = |sql: &'static str| sqlx::query_scalar::<_, String>(sql).fetch_one(&db);
assert_eq!(
text("SELECT (policy->>'on_behalf_of') || ' ' || (policy->>'on_behalf_of_email') FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger'").await?,
"u/test-user test@windmill.dev"
);
assert_eq!(
text("SELECT policy->>'on_behalf_of' FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/group'").await?,
"g/all"
);
assert_eq!(
text("SELECT value->'policy'->>'on_behalf_of' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger' AND typ = 'raw_app'").await?,
"u/test-user"
);
// `clone_drafts` strips a NUL escape as it copies, so the row reaches the fork
// parseable and the repoint below reaches it like any other draft's. The rule this
// guards is that the fork completes and no identity naming nobody survives it; the
// skip only ever existed because `to_jsonb` raises on a value still holding one.
assert_eq!(
text("SELECT CASE WHEN strpos(value::text, 'u/test-user-2') > 0 THEN 'kept' ELSE 'rewritten' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
"rewritten"
);
// And it arrives without the poison that made it a special case.
assert_eq!(
text("SELECT CASE WHEN position(chr(92) || 'u0000' in value::text) > 0 THEN 'poisoned' ELSE 'clean' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
"clean"
);
assert_eq!(
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
"u/test-user"
);
assert_eq!(
text("SELECT permissioned_as || ' ' || email FROM schedule WHERE workspace_id = 'wm-fork-repoint'").await?,
"u/test-user test@windmill.dev"
);
assert_eq!(
text(
"SELECT permissioned_as FROM websocket_trigger WHERE workspace_id = 'wm-fork-repoint'"
)
.await?,
"u/test-user"
);
Ok(())
}
@@ -1,75 +0,0 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and
/// developers at their parent role, even when a developer forks it; operators are left out. The
/// copies are manual members: a parent membership that came from an instance group must not carry
/// that provenance into a fork that does not configure the group.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_adds_parent_admins_and_developers(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!(
"http://localhost:{}/api/w/test-workspace/workspaces",
server.addr.port()
);
let client = reqwest::Client::new();
sqlx::query(
"UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, added_via)
VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false,
'{\"source\": \"instance_group\", \"group\": \"devs\"}')",
)
.execute(&db)
.await?;
let resp = client
.post(format!(
"{base_url}/edit_add_admins_and_developers_to_forks"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "add_admins_and_developers_to_forks": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"enabling the setting: {}",
resp.text().await?
);
let resp = client
.post(format!("{base_url}/create_fork"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({ "id": "wm-fork-team", "name": "Team fork" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"creating the fork: {}",
resp.text().await?
);
let members: Vec<(String, bool, bool)> = sqlx::query_as(
"SELECT username, is_admin, added_via IS NULL FROM usr
WHERE workspace_id = 'wm-fork-team' ORDER BY username",
)
.fetch_all(&db)
.await?;
assert_eq!(
members,
vec![
("test-user".to_string(), true, true),
("test-user-2".to_string(), false, true),
("test-user-4".to_string(), false, true),
]
);
Ok(())
}
@@ -115,60 +115,6 @@ async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow:
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn confirmed_login_link_is_spent_by_the_click_not_the_page(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let resp = client()
.post(format!("{base}/users/login_links"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"email": "test2@windmill.dev", "confirm": true}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let link = resp.json::<serde_json::Value>().await?;
// The URL handed out is the frontend page, not the API path that signs in on a GET.
let token = link["url"]
.as_str()
.unwrap()
.split_once("/user/login_link?token=")
.expect("confirmation page url")
.1
.to_string();
let confirm = || {
client()
.post(format!("{base}/auth/login_link/{token}"))
.send()
};
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp
.headers()
.get_all("set-cookie")
.iter()
.any(|c| c.to_str().unwrap().starts_with("token=")));
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/workspaces"
);
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp.headers().get("set-cookie").is_none());
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/login_link_expired?reason=used"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -1,239 +0,0 @@
//! `max_token_expiration_days`: the instance-wide ceiling on how far ahead a token a caller
//! picks the lifetime of may expire, and the service-account exemption.
use serde_json::json;
use sqlx::types::chrono::{DateTime, Utc};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const DAY: u64 = 24 * 60 * 60;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn from_now(secs: u64) -> DateTime<Utc> {
Utc::now() + std::time::Duration::from_secs(secs)
}
async fn set_max(db: &Pool<Postgres>, value: serde_json::Value) {
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ('max_token_expiration_days', $1)
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind(value)
.execute(db)
.await
.unwrap();
}
/// Mints as `test2@windmill.dev`, a plain member of `test-workspace`.
async fn create_token(port: u16, body: serde_json::Value) -> reqwest::Response {
client()
.post(format!("http://localhost:{port}/api/users/tokens/create"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&body)
.send()
.await
.unwrap()
}
async fn stored_expiration(db: &Pool<Postgres>, label: &str) -> Option<DateTime<Utc>> {
sqlx::query_scalar::<_, Option<DateTime<Utc>>>("SELECT expiration FROM token WHERE label = $1")
.bind(label)
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_max_token_expiration_days_shortens_user_tokens(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = create_token(port, json!({ "label": "unset" })).await;
assert_eq!(resp.status(), 201);
assert_eq!(
stored_expiration(&db, "unset").await,
None,
"with no setting a token may still have no expiration"
);
// Refused at write time: the token routes can only read a value they cannot parse as no
// ceiling at all.
let resp = client()
.post(format!(
"http://localhost:{port}/api/settings/global/max_token_expiration_days"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "value": 7.5 }))
.send()
.await?;
assert_eq!(resp.status(), 400);
set_max(&db, json!(7)).await;
// The token form reads the ceiling as whoever is creating the token, usually not a
// superadmin, so it can offer only expirations the server would keep.
let resp = client()
.get(format!(
"http://localhost:{port}/api/settings/global/max_token_expiration_days"
))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.send()
.await?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await?, "7");
let resp = create_token(port, json!({ "label": "none asked" })).await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, "none asked")
.await
.expect("a token asking for no expiration gets the ceiling");
assert!(
expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY),
"expected the 7 day ceiling, got {expiration}"
);
let resp = create_token(
port,
json!({ "label": "past the ceiling", "expiration": from_now(30 * DAY) }),
)
.await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, "past the ceiling").await.unwrap();
assert!(
expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY),
"expected an expiration past the ceiling to be shortened to it, got {expiration}"
);
let resp = create_token(
port,
json!({ "label": "within", "expiration": from_now(3 * DAY) }),
)
.await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, "within").await.unwrap();
assert!(
expiration <= from_now(3 * DAY),
"an expiration within the ceiling must be kept, got {expiration}"
);
// The settings UI stores an integer, but the YAML instance config and config sync can write
// the same whole number as a string or as `5.0`. Reading either as "unset" would silently
// drop the ceiling, while the token form (`parseMaxTokenExpirationDays`) still showed it.
for (stored, label) in [
(json!("5"), "string setting"),
(json!(5.0), "float setting"),
] {
set_max(&db, stored).await;
let resp = create_token(port, json!({ "label": label })).await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, label).await;
assert!(
expiration.is_some_and(|e| e > from_now(4 * DAY) && e <= from_now(5 * DAY)),
"{label}: expected the 5 day ceiling, got {expiration:?}"
);
}
// A superadmin impersonating a user picks the lifetime too, so the ceiling applies there;
// left out, it would be the one way to mint a token that never expires.
let resp = client()
.post(format!(
"http://localhost:{port}/api/users/tokens/impersonate"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "label": "impersonated", "impersonate_email": "test3@windmill.dev" }))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert!(
stored_expiration(&db, "impersonated").await.is_some(),
"an impersonation token asking for no expiration gets the ceiling"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_service_accounts_are_exempt(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
set_max(&db, json!(7)).await;
// The same email is a service account in one workspace and an ordinary user in another.
sqlx::query(
"UPDATE usr SET is_service_account = true
WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')")
.execute(&db)
.await?;
sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other')")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
VALUES ('other', 'test2@windmill.dev', 'test-user-2', false, 'User')",
)
.execute(&db)
.await?;
// The token form decides the exemption from this list, so it must carry each membership's flag.
let memberships: serde_json::Value = client()
.get(format!("http://localhost:{port}/api/workspaces/users"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.send()
.await?
.json()
.await?;
for (workspace, is_service_account) in [("test-workspace", true), ("other", false)] {
let membership = memberships["workspaces"]
.as_array()
.and_then(|ws| ws.iter().find(|w| w["id"] == workspace))
.unwrap_or_else(|| panic!("{workspace} missing from {memberships}"));
assert_eq!(membership["is_service_account"], json!(is_service_account));
}
for (label, workspace_id, exempt) in [
("own workspace", Some("test-workspace"), true),
("other workspace", Some("other"), false),
// A workspace-less token has no workspace to match, so a service account anywhere counts.
("global", None, true),
] {
let resp = create_token(
port,
json!({ "label": label, "workspace_id": workspace_id }),
)
.await;
assert_eq!(resp.status(), 201);
assert_eq!(
stored_expiration(&db, label).await.is_none(),
exempt,
"{label}: expected exempt = {exempt}"
);
}
// Impersonation checks the impersonated account, not the superadmin minting the token.
let resp = client()
.post(format!(
"http://localhost:{port}/api/users/tokens/impersonate"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "label": "impersonated service account", "impersonate_email": "test2@windmill.dev" }))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert_eq!(
stored_expiration(&db, "impersonated service account").await,
None
);
Ok(())
}
@@ -1,102 +0,0 @@
//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path.
//!
//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and
//! everything that reads a token, on both the workspaced and the gateway mount. Each half of
//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer
//! being moved outward (a client would lose the pointer that starts OAuth discovery), and
//! refusing a token that was never valid catches it being moved inward past authentication
//! (the URL-borne token would be hashed and looked up before anything refused it).
#![cfg(feature = "mcp")]
use std::sync::atomic::Ordering;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM;
use windmill_test_utils::*;
/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the
/// workspaced mount takes its workspace from the path, so one token reaches both.
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
)
.execute(db)
.await?;
Ok(())
}
/// A token that is not in `token` at all. Authentication would refuse it on its own, so a
/// refusal carrying the middleware's own wording is evidence nothing looked it up first.
const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN";
async fn tools_list(url: &str) -> anyhow::Result<reqwest::Response> {
Ok(reqwest::Client::new()
.post(url)
.header("Accept", "application/json, text/event-stream")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?)
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mcp_token_query_param_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
insert_mcp_token(&db).await?;
let server = ApiServer::start_mcp(db.clone()).await?;
let port = server.addr.port();
let workspaced =
format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN");
let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN");
assert_eq!(
tools_list(&workspaced).await?.status(),
200,
"a URL-borne token is the documented default and must keep working while the switch is off"
);
assert_eq!(tools_list(&gateway).await?.status(), 200);
MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed);
for url in [&workspaced, &gateway] {
let resp = tools_list(url).await?;
assert_eq!(
resp.status(),
401,
"{url} still admitted a token in the URL"
);
// What sends the client into the OAuth flow rather than leaving it stuck on a 401.
assert!(
resp.headers().contains_key("www-authenticate"),
"{url} rejected without pointing at the authorization server"
);
}
// Refused before authentication, not after: an invalid token gets the middleware's own
// message rather than the generic 401 that looking it up would produce.
let resp = tools_list(&format!(
"http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}"
))
.await?;
assert_eq!(resp.status(), 401);
assert!(
resp.text().await?.contains("does not accept a token in the MCP URL"),
"an invalid URL token was answered by authentication, so the token was read before \
the switch refused it"
);
// The header stays open: it is the channel the OAuth flow itself hands tokens over on.
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/mcp/gateway"))
.header("Accept", "application/json, text/event-stream")
.header("Authorization", "Bearer MCP_TOKEN")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?;
assert_eq!(resp.status(), 200);
Ok(())
}
@@ -1,49 +0,0 @@
//! Which user tokens get an "expiring soon" warning queued when they are created.
use serde_json::json;
use sqlx::types::chrono::Utc;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const DAY: u64 = 24 * 60 * 60;
async fn warning_queued(db: &Pool<Postgres>, label: &str) -> bool {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM token_expiry_notification n
JOIN token t ON t.token_hash = n.token_hash WHERE t.label = $1)",
)
.bind(label)
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_short_lived_tokens_get_no_expiry_warning(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for (label, lifetime) in [("short", DAY), ("long", 30 * DAY)] {
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/users/tokens/create"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({
"label": label,
"expiration": Utc::now() + std::time::Duration::from_secs(lifetime),
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
}
assert!(
!warning_queued(&db, "short").await,
"a token whose whole lifetime fits in the warning window must not be warned about"
);
assert!(
warning_queued(&db, "long").await,
"a longer-lived token still gets its warning"
);
Ok(())
}
@@ -275,67 +275,6 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync(
Ok(())
}
/// Stored repository tokens and webhook secrets are encrypted under the
/// workspace key but never synced, so a rotation has to carry them over even
/// when the caller skips re-encrypting variables.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_encryption_key_rotation_reencrypts_git_sync_secrets(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt};
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_git_secrets";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let mc = build_crypt(&db, "test-workspace").await?;
sqlx::query(
r#"
UPDATE workspace_settings SET
git_credentials = jsonb_build_array(jsonb_build_object(
'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')),
git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object(
'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text))
WHERE workspace_id = 'test-workspace'
"#,
)
.bind(encrypt(&mc, "stored-token"))
.bind(encrypt(&mc, "hook-secret"))
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let new_key = "c".repeat(64);
let resp = authed(client().post(format!("{base}/encryption_key")))
.json(&json!({"new_key": new_key, "skip_reencrypt": true}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"set_encryption_key failed: {}",
resp.text().await?
);
let (token, secret): (String, String) = sqlx::query_as(
"SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}'
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
let new_mc = crypt_from_key_with_suffix(&new_key, "");
assert_eq!(decrypt(&new_mc, token)?, "stored-token");
assert_eq!(decrypt(&new_mc, secret)?, "hook-secret");
Ok(())
}
/// Regression test for the non-debouncing fallback: a workspace whose sync
/// script predates hub version 28103 must still receive git-sync jobs for the
/// encryption_key entry and every re-encrypted secret. Before the fallback was
@@ -1166,47 +1166,3 @@ async fn test_create_service_account_drops_orphaned_group_memberships(
Ok(())
}
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_edit_auto_invite_preserves_instance_groups(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
r#"UPDATE workspace_settings
SET auto_invite = '{"instance_groups": ["eng"], "instance_groups_roles": {"eng": "developer"}}'
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// enable, then disable
for body in [
json!({"operator": false, "invite_all": true, "auto_add": false}),
json!({}),
] {
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
.json(&body)
.send()
.await?;
assert_eq!(resp.status(), 200, "{body}: {}", resp.text().await?);
let auto_invite: serde_json::Value = sqlx::query_scalar(
"SELECT auto_invite FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
assert_eq!(auto_invite["instance_groups"], json!(["eng"]), "{body}");
assert_eq!(
auto_invite["instance_groups_roles"],
json!({"eng": "developer"}),
"{body}"
);
}
Ok(())
}
+16 -52
View File
@@ -55,7 +55,7 @@ use windmill_common::{
min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait,
},
scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline},
utils::{paginate_optional, paginate_without_limits, WarnAfterExt},
utils::{paginate_without_limits, WarnAfterExt},
worker::CLOUD_HOSTED,
};
use windmill_object_store::upload_artifact_to_store;
@@ -2312,20 +2312,6 @@ async fn create_script_internal<'c>(
.await?;
}
if p_path != &ns.path {
// Everything left at the old path is a draft this deploy didn't
// consume — teammates' rows, and the deployer's own when the caller
// asked us to keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
p_path,
&ns.path,
)
.await?;
}
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
ns.path,
@@ -2403,32 +2389,19 @@ async fn create_script_internal<'c>(
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
}
}
} else {
if !skip_draft_deletion {
// See the matching branch above — only wipe the deployer's own
// draft (plus the legacy NULL-email row).
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
AND (email = $3 OR email IS NULL)",
ns.path,
&w_id,
&authed.email,
)
.execute(&mut *tx)
.await?;
}
} else if !skip_draft_deletion {
// See the matching branch above — only wipe the deployer's own
// draft (plus the legacy NULL-email row).
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
AND (email = $3 OR email IS NULL)",
ns.path,
&w_id,
&authed.email,
)
.execute(&mut *tx)
.await?;
}
// Every deploy, not only a new script: an archived script's draft can be moved away
// (`move_draft` ignores archived rows), and unarchiving redeploys at the same path,
// where a route left behind would send the live script's saves to the moved draft.
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
&ns.path,
p_path_opt.as_deref(),
)
.await?;
if p_hashes.is_some() && !p_hashes.unwrap().is_empty() {
audit_log(
&mut *tx,
@@ -3088,24 +3061,17 @@ async fn get_script_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<ScriptHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("scripts:read:{}", path))?;
// Unasked-for, this listing stays whole: the deployment-history panels, the restart
// picker and the CLI all read it without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
let query_result = sqlx::query!(
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
WHERE s.workspace_id = $1 AND s.path = $2
ORDER by s.created_at DESC
LIMIT $3 OFFSET $4",
ORDER by s.created_at DESC",
w_id,
path,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
@@ -3117,7 +3083,6 @@ async fn get_script_history(
script_hash: ScriptHash(row.hash),
deployment_msg: row.deployment_msg,
created_at: Some(row.created_at),
created_by: Some(row.created_by),
})
.collect();
return Ok(Json(result));
@@ -3132,7 +3097,7 @@ async fn get_latest_version(
check_scopes(&authed, || format!("scripts:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let row_o = sqlx::query!(
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
WHERE s.workspace_id = $1 AND s.path = $2
ORDER by s.created_at DESC LIMIT 1",
@@ -3148,7 +3113,6 @@ async fn get_latest_version(
script_hash: ScriptHash(row.hash),
deployment_msg: row.deployment_msg,
created_at: Some(row.created_at),
created_by: Some(row.created_by),
};
return Ok(Json(Some(result)));
} else {
+201 -25
View File
@@ -42,7 +42,6 @@ use axum::{
routing::{get, post},
Json, Router,
};
use serde_json::json;
use serde::{Deserialize, Serialize};
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
@@ -60,11 +59,10 @@ use windmill_common::{
global_settings::{
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING,
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
@@ -169,6 +167,22 @@ pub fn global_service() -> Router {
"/refresh_custom_instance_user_pwd",
post(refresh_custom_instance_user_pwd),
)
.route(
"/external_instance_pg/status",
get(get_external_instance_pg_status),
)
.route(
"/external_instance_pg/setup",
post(setup_external_instance_pg),
)
.route(
"/external_instance_pg/databases",
get(list_external_instance_pg_databases),
)
.route(
"/external_instance_pg/databases/{name}",
post(create_external_instance_pg_database).delete(drop_external_instance_pg_database),
)
.route(
"/setup_custom_instance_pg_database/{name}",
post(setup_custom_instance_pg_database),
@@ -878,6 +892,14 @@ pub async fn set_global_setting_internal(
)));
}
if key == EXTERNAL_INSTANCE_PG_SETTING {
return windmill_common::external_instance_pg::write_external_instance_pg_setting(
db,
Some(&value),
)
.await;
}
run_setting_pre_write_hook(db, &key, &value).await?;
match value {
@@ -1196,12 +1218,6 @@ async fn run_setting_pre_write_hook(
}
}
}
MAX_TOKEN_EXPIRATION_DAYS_SETTING => {
windmill_common::global_settings::parse_max_token_expiration_days(Some(value))
.map_err(|e| {
error::Error::BadRequest(format!("{MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}"))
})?;
}
INSTANCE_BANNER_SETTING => {
match value {
// Clearing (delete row) is handled by the caller; allow it through.
@@ -1265,7 +1281,7 @@ async fn set_instance_config(
let desired_map = desired.global_settings.to_settings_map();
if !desired_map.is_empty() {
let current_map = current.global_settings.to_settings_map();
let settings_diff =
let mut settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
let ai_config_changed = settings_diff
.upserts
@@ -1294,8 +1310,15 @@ async fn set_instance_config(
}
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
if key != EXTERNAL_INSTANCE_PG_SETTING {
run_setting_pre_write_hook(&db, key, value).await?;
}
}
windmill_common::external_instance_pg::write_external_instance_pg_from_diff(
&db,
&mut settings_diff,
)
.await?;
instance_config::apply_settings_diff(&db, &settings_diff)
.await
@@ -1363,12 +1386,6 @@ pub async fn get_global_setting(
&& key != HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING
&& key != WS_BASE_URL_SETTING
&& key != INSTANCE_BANNER_SETTING
// The token form reads it to stop offering expirations the server would shorten.
&& key != MAX_TOKEN_EXPIRATION_DAYS_SETTING
// Whoever is wiring up an MCP client reads it to know whether a URL-borne token
// would be refused, and they are usually not a superadmin. Not a secret: pointing
// any MCP client at the instance discovers the same answer.
&& key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING
{
require_super_admin(&db, &authed).await?;
}
@@ -1660,6 +1677,8 @@ struct CustomInstanceDb {
tag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
used_by_workspaces: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
}
#[derive(Deserialize, Debug, Serialize, Default)]
@@ -1700,7 +1719,15 @@ async fn list_custom_instance_pg_databases(
))
})?;
if windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
// Which workspace reserved a fork copy is nobody else's business: it would enumerate every
// pending fork on the instance.
for entry in result.values_mut() {
entry.workspace_id = None;
}
return Ok(Json(result));
}
{
// Enrich each database with the list of workspaces referencing it through
// either a ducklake catalog or a datatable database whose resource_type is
// 'instance'. Not stored in DB to avoid drift.
@@ -1756,6 +1783,135 @@ async fn refresh_custom_instance_user_pwd(
Ok(Json(()))
}
async fn get_external_instance_pg_status(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<windmill_common::external_instance_pg::ExternalInstancePgStatus> {
require_super_admin(&db, &authed).await?;
Ok(Json(
windmill_common::external_instance_pg::external_instance_pg_status(&db).await?,
))
}
#[derive(Deserialize)]
struct SetupExternalInstancePgBody {
#[serde(default)]
rotate_passwords: bool,
}
async fn setup_external_instance_pg(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Json(body): Json<SetupExternalInstancePgBody>,
) -> JsonResult<windmill_common::external_instance_pg::ExternalInstancePgSetupReport> {
require_super_admin(&db, &authed).await?;
let report = windmill_common::external_instance_pg::setup_external_instance_pg_unchecked(
&db,
body.rotate_passwords,
)
.await?;
let rotated = body.rotate_passwords.to_string();
let success = report.success.to_string();
windmill_audit::audit_oss::audit_log(
&db,
&authed,
"settings.setup_external_instance_pg",
windmill_audit::ActionKind::Update,
"global",
Some(&authed.email),
Some(
[
("rotate_passwords", rotated.as_str()),
("success", success.as_str()),
]
.into(),
),
)
.await?;
Ok(Json(report))
}
#[derive(Serialize)]
struct ExternalInstancePgDatabase {
#[serde(flatten)]
status: windmill_common::instance_config::CustomInstanceDb,
used_by_workspaces: Vec<String>,
}
async fn list_external_instance_pg_databases(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<std::collections::BTreeMap<String, ExternalInstancePgDatabase>> {
require_super_admin(&db, &authed).await?;
let databases = windmill_common::external_instance_pg::external_instance_databases(&db).await?;
let mut usages =
windmill_common::external_instance_pg::external_instance_database_usages(&db).await?;
Ok(Json(
databases
.into_iter()
.map(|(name, status)| {
let used_by_workspaces = usages.remove(&name).unwrap_or_default();
(
name,
ExternalInstancePgDatabase {
status,
used_by_workspaces: used_by_workspaces.into_iter().collect(),
},
)
})
.collect(),
))
}
async fn create_external_instance_pg_database(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(dbname): Path<String>,
Json(body): Json<SetupCustomInstanceDbBody>,
) -> JsonResult<()> {
require_super_admin(&db, &authed).await?;
let tag = body.tag.as_deref().unwrap_or("datatable");
windmill_common::external_instance_pg::create_external_instance_database_unchecked(
&db, &dbname, tag, None,
)
.await?;
windmill_audit::audit_oss::audit_log(
&db,
&authed,
"settings.create_external_instance_pg_database",
windmill_audit::ActionKind::Create,
"global",
Some(&authed.email),
Some([("dbname", dbname.as_str()), ("tag", tag)].into()),
)
.await?;
Ok(Json(()))
}
async fn drop_external_instance_pg_database(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(dbname): Path<String>,
) -> JsonResult<()> {
require_super_admin(&db, &authed).await?;
// A data table naming a dropped database fails on every job, far from the drop that caused it.
windmill_common::external_instance_pg::drop_external_instance_database_unchecked(
&db, &dbname, None,
)
.await?;
windmill_audit::audit_oss::audit_log(
&db,
&authed,
"settings.drop_external_instance_pg_database",
windmill_audit::ActionKind::Delete,
"global",
Some(&authed.email),
Some([("dbname", dbname.as_str())].into()),
)
.await?;
Ok(Json(()))
}
#[derive(Deserialize)]
struct SetupCustomInstanceDbBody {
tag: Option<String>,
@@ -1767,18 +1923,38 @@ async fn setup_custom_instance_pg_database(
Path(dbname): Path<String>,
Json(body): Json<SetupCustomInstanceDbBody>,
) -> JsonResult<CustomInstanceDb> {
// Before anything is recorded: the status written below replaces the registry entry, and with it
// the workspace a fork copy is reserved for.
require_super_admin(&db, &authed).await?;
let mut logs = CustomInstanceDbLogs::default();
let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await;
let success = result.is_ok();
let error = result.err().map(|e| e.to_string());
let status =
CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] };
let status = CustomInstanceDb {
logs,
success,
error,
tag: body.tag,
used_by_workspaces: vec![],
workspace_id: None,
};
let status_json = serde_json::to_value(&status).map_err(to_anyhow)?;
// Save that the database was setup successfully
sqlx::query!(
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#,
json!({ dbname: status_json })
).execute(&db).await?;
// The fork reservation is carried over inside the write, from whatever the row holds then: a
// rename migrating it while the setup above ran would otherwise be overwritten with the value
// this request started from, stranding the copy under the archived workspace.
let saved = sqlx::query_scalar::<_, serde_json::Value>(
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}',
COALESCE(value->'databases', '{}'::jsonb)
|| jsonb_build_object($1::text, $2::jsonb || jsonb_build_object(
'workspace_id', value->'databases'->$1::text->'workspace_id')))
WHERE name = 'custom_instance_pg_databases'
RETURNING value->'databases'->$1::text"#,
)
.bind(&dbname)
.bind(&status_json)
.fetch_one(&db)
.await?;
let status: CustomInstanceDb = serde_json::from_value(saved).map_err(to_anyhow)?;
Ok(Json(status))
}
+22 -127
View File
@@ -47,10 +47,7 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
use windmill_common::auth::{hash_token, safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::global_settings::{
load_value_from_global_settings, parse_max_token_expiration_days,
AUTOMATE_USERNAME_CREATION_SETTING, MAX_TOKEN_EXPIRATION_DAYS_SETTING,
};
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::per_minute_counter::PerMinuteCounter;
use windmill_common::users::truncate_token;
@@ -171,10 +168,7 @@ pub fn make_unauthed_service() -> Router {
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route(
"/login_link/{token}",
get(consume_login_link).post(confirm_login_link),
)
.route("/login_link/{token}", get(consume_login_link))
.route("/is_smtp_configured", get(is_smtp_configured))
.route(
"/is_password_login_disabled",
@@ -3098,68 +3092,11 @@ pub async fn create_guest_session_token<'c>(
// create_token_internal is re-exported from windmill-api-auth above
/// Applies the instance-wide ceiling on how long a token a caller picks the lifetime of may
/// live (`create_token`, and `impersonate` for superadmins), returning the expiration to store:
/// the requested one while it fits, the ceiling otherwise, and the ceiling as well when none was
/// requested. Only the stored expiration is capped: tokens already stored when the setting is
/// turned on or lowered keep theirs, since the auth lookup never reads the setting.
///
/// It shortens rather than refuses because most callers do not comply on their own. The CLI
/// authorization page, `wmill user create-token` and the editor's language-server token each
/// pick a lifetime, often none at all, without reading the setting (and CLIs already installed
/// never will), so refusing would break logging in and the editor instead of the long-lived
/// tokens the setting is aimed at.
///
/// Read from `global_settings` on each call rather than cached: token creation is rare
/// enough that the round trip costs nothing, and the ceiling is then never served stale.
///
/// A token owned by a service account is exempt: in the workspace the token names, or in any
/// workspace for a workspace-less token, which has none to match. Service accounts are the
/// identity automation that needs a long-lived credential runs as. The cost is that any
/// workspace admin can create and impersonate one to hold an uncapped token, so the ceiling
/// bounds personal tokens rather than what an admin can obtain.
async fn cap_token_expiration(
db: &DB,
owner_email: &str,
workspace_id: Option<&str>,
requested: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
let value = load_value_from_global_settings(db, MAX_TOKEN_EXPIRATION_DAYS_SETTING).await?;
let max_days = match parse_max_token_expiration_days(value.as_ref()) {
Ok(Some(max_days)) => max_days,
Ok(None) => return Ok(requested),
// Both write paths reject this, so only a row written around them gets here.
Err(e) => {
tracing::warn!("ignoring {MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}");
return Ok(requested);
}
};
let max = chrono::Utc::now() + chrono::Duration::days(max_days);
let is_service_account = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true
AND ($2::varchar IS NULL OR workspace_id = $2))",
owner_email,
workspace_id,
)
.fetch_one(db)
.await?
.unwrap_or(false);
if is_service_account {
return Ok(requested);
}
Ok(Some(match requested {
Some(expiration) if expiration < max => expiration,
_ => max,
}))
}
async fn create_token(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(mut token_config): Json<NewToken>,
Json(token_config): Json<NewToken>,
) -> Result<(StatusCode, String)> {
forbid_elevated_job_token(&db, &authed.email, job_id).await?;
check_token_create_rate_limit(&authed.username)?;
@@ -3181,14 +3118,6 @@ async fn create_token(
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
token_config.expiration = cap_token_expiration(
&db,
&authed.email,
token_config.workspace_id.as_deref(),
token_config.expiration,
)
.await?;
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
@@ -3245,7 +3174,6 @@ async fn impersonate(
.fetch_optional(&db)
.await?
.unwrap_or(false);
let expiration = cap_token_expiration(&db, &impersonated, None, new_token.expiration).await?;
let mut tx = db.begin().await?;
sqlx::query!(
@@ -3257,7 +3185,7 @@ async fn impersonate(
plaintext as Option<&str>,
impersonated,
new_token.label,
expiration,
new_token.expiration,
is_super_admin
)
.execute(&mut *tx)
@@ -3267,7 +3195,7 @@ async fn impersonate(
&mut *tx,
&t_hash,
new_token.label.as_deref(),
expiration,
new_token.expiration,
)
.await;
@@ -3287,12 +3215,9 @@ async fn impersonate(
}
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
// Long enough for a link sent by email to still work when it is read. `require_login_type` is
// only checked at mint, so a much longer cap would need re-checking it when the link is opened.
const LOGIN_LINK_MAX_TTL_S: u32 = 7200;
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link";
#[derive(Deserialize)]
pub struct NewLoginLink {
@@ -3303,9 +3228,6 @@ pub struct NewLoginLink {
/// account it created can require `pending_oauth`, so the link stops working once the
/// owner has set a password or signed in with a provider.
pub require_login_type: Option<String>,
/// Hand out a page that signs in only when its button is clicked. Mail scanners open links
/// on delivery, and opening the plain link spends it, so a link sent by email sets this.
pub confirm: Option<bool>,
}
#[derive(Serialize)]
@@ -3456,12 +3378,11 @@ async fn create_login_link(
.await?;
tx.commit().await?;
let base_url = (**BASE_URL.load()).clone();
let url = if nl.confirm.unwrap_or(false) {
format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}")
} else {
format!("{base_url}/api/auth/login_link/{token}")
};
let url = format!(
"{}/api/auth/login_link/{}",
(**BASE_URL.load()).clone(),
token
);
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
}
@@ -3707,45 +3628,19 @@ async fn consume_login_link(
Path(token): Path<String>,
Query(query): Query<LoginLinkQuery>,
) -> Result<Response> {
let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?;
Ok(login_link_redirect(location))
}
#[derive(Serialize)]
struct LoginLinkLocation {
location: String,
}
/// The confirmation page's click. It answers with where to go rather than redirecting, and the
/// page navigates there itself.
async fn confirm_login_link(
headers: axum::http::HeaderMap,
cookies: Cookies,
Extension(db): Extension<DB>,
Path(token): Path<String>,
) -> JsonResult<LoginLinkLocation> {
let location = redeem_login_link(&headers, cookies, &db, &token, None).await?;
Ok(Json(LoginLinkLocation { location }))
}
/// Spends the link and sets the session cookie, returning the post-login destination; or
/// returns the explanation page, with no session, when the link cannot be used.
async fn redeem_login_link(
headers: &axum::http::HeaderMap,
cookies: Cookies,
db: &DB,
token: &str,
requested_rd: Option<String>,
) -> Result<String> {
let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"));
let bounce = |reason: &str| {
Ok(login_link_redirect(format!(
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
)))
};
if token.len() != 32 {
return bounce("invalid");
}
let t_hash = hash_token(token);
let t_hash = hash_token(&token);
// The account is unknown until the row is read, so only the global and per-IP tiers
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
windmill_common::login_rate_limit::check_and_increment_login_attempt(
headers,
&headers,
&t_hash[..TOKEN_PREFIX_LEN],
)?;
@@ -3812,10 +3707,11 @@ async fn redeem_login_link(
.await?;
tx.commit().await?;
Ok(link
let rd = link
.rd
.or_else(|| same_origin_rd(requested_rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()))
.or_else(|| same_origin_rd(query.rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
Ok(login_link_redirect(rd))
}
#[derive(Deserialize)]
@@ -4049,7 +3945,6 @@ async fn update_token_label(
AND NOT starts_with(label, 'embed_app:')
AND NOT starts_with(label, 'sdk_app:')
AND NOT starts_with(label, 'impersonation:')
AND NOT starts_with(label, 'cli-login:')
))
RETURNING token_prefix",
req.label.as_deref(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
/*
* 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.
*/
//! Where the ACL planner comes from: the enterprise one, or a refusal.
//!
//! Data table roles are an Enterprise Edition feature, and so is everything here — reading who
//! owns what included. `private` alone is not that edition — community builds carry it — so the
//! planner is behind `enterprise` as well.
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) use crate::datatable_acl_ee::plan_statements;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) fn ensure_datatable_acl_available() -> windmill_common::error::Result<()> {
Ok(())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {
crate::datatable_acl::{AclChange, AclPlan, AclTarget, CatalogFacts},
windmill_common::{datatable_roles_oss::datatable_roles_unavailable, error::Result},
};
/// Checked first by every ACL route, before anything is read or connected to.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn ensure_datatable_acl_available() -> Result<()> {
Err(datatable_roles_unavailable())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn plan_statements(
_target: &AclTarget,
_change: &AclChange,
_dbname: &str,
_pg_role: &str,
_facts: &CatalogFacts,
) -> Result<AclPlan> {
Err(datatable_roles_unavailable())
}
@@ -11,7 +11,7 @@
//! to keep that file focused on core workspace configuration.
use crate::workspaces::{
is_instance_datatable, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison,
managed_datatable_kind, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison,
PgDumpOptions,
};
@@ -1556,7 +1556,9 @@ async fn generate_initial_datatable_migration(
// without what a replay elsewhere cannot run: the replaying user owns none of this
// database's objects, and the grants Windmill plants in an instance database (`ALTER
// DEFAULT PRIVILEGES FOR ROLE ...`) fail even replaying onto the same server.
let no_acl = is_instance_datatable(&db, &w_id, &datatable_name).await?;
let no_acl = managed_datatable_kind(&db, &w_id, &datatable_name)
.await?
.is_some();
let dump_file = pg_dump_database(
&pg_db,
PgDumpOptions {
@@ -65,3 +65,15 @@ pub(crate) async fn ensure_reaches_datatable(
) -> Result<()> {
roles::ensure_reaches_datatable(db, w_id, datatable_name, authed).await
}
/// [`ensure_reaches_datatable`] against an entry already resolved, for a caller that goes on to
/// connect from that same entry.
pub(crate) async fn ensure_reaches_governing_datatable(
db: &DB,
w_id: &str,
datatable_name: &str,
governing: &GoverningDatatable,
authed: &ApiAuthed,
) -> Result<()> {
roles::ensure_reaches_governing_datatable(db, w_id, datatable_name, governing, authed).await
}
@@ -12,8 +12,9 @@
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) use crate::datatable_permissions_ee::{
ensure_governs_datatable, ensure_reaches_datatable, get_datatable_permissions,
list_usable_datatable_roles, set_datatable_permissions,
ensure_governs_datatable, ensure_reaches_datatable, ensure_reaches_governing_datatable,
get_datatable_permissions, list_usable_datatable_roles, set_datatable_permissions,
usable_datatable_roles,
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
@@ -56,6 +57,20 @@ mod ce {
}
}
pub(crate) async fn ensure_reaches_governing_datatable(
_db: &DB,
_w_id: &str,
_datatable_name: &str,
governing: &GoverningDatatable,
_authed: &ApiAuthed,
) -> Result<()> {
if governing.datatable.permissions.is_none() {
Ok(())
} else {
Err(unavailable())
}
}
// The routes stay registered so the API has one shape; each answers after authentication,
// before anything is read.
@@ -70,4 +85,28 @@ mod ce {
pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
pub(crate) struct UsableDatatableRoles {
pub(crate) permissioned: bool,
pub(crate) roles: Vec<String>,
pub(crate) default_role: String,
}
/// A data table not under roles is used as `admin`, as before roles existed. One under roles
/// is refused: no role of it can be connected as.
pub(crate) async fn usable_datatable_roles(
_db: &DB,
_authed: &ApiAuthed,
_w_id: &str,
governing: &GoverningDatatable,
) -> Result<UsableDatatableRoles> {
if governing.datatable.permissions.is_some() {
return Err(unavailable());
}
Ok(UsableDatatableRoles {
permissioned: false,
roles: vec![],
default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(),
})
}
}
@@ -1,6 +1,8 @@
#[cfg(feature = "parquet")]
pub mod ai_session_backups;
pub mod data_metrics;
pub mod datatable_acl;
pub mod datatable_acl_oss;
pub mod datatable_migrations;
pub mod datatable_permissions;
pub mod datatable_permissions_oss;
@@ -12,5 +14,8 @@ pub mod workspaces_oss;
#[cfg(feature = "private")]
pub mod workspaces_ee;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_acl_ee;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_permissions_ee;
File diff suppressed because it is too large Load Diff
@@ -110,10 +110,25 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
// A fork copy reserved for the old id would otherwise be unreachable: its creator cannot
// import into it or finish its fork under the new id, and nothing else would ever drop it.
sqlx::query(
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (
SELECT COALESCE(jsonb_object_agg(k, CASE WHEN v->>'workspace_id' = $1
THEN jsonb_set(v, '{workspace_id}', to_jsonb($2::text)) ELSE v END), '{}'::jsonb)
FROM jsonb_each(COALESCE(value->'databases', '{}'::jsonb)) AS e(k, v)
))
WHERE name = 'custom_instance_pg_databases'"#,
)
.bind(&old_id)
.bind(&rw.new_id)
.execute(&mut *tx)
.await?;
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, 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 = $2",
&rw.new_id,
&old_id
)
@@ -382,13 +397,6 @@ pub(crate) async fn change_workspace_id(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating favorite table");
sqlx::query!(
@@ -1416,9 +1424,7 @@ pub async fn drop_forked_datatable_databases(
_ => continue,
};
if database.resource_type
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
{
if database.resource_type.is_windmill_managed() {
let db_to_drop = &database.resource_path;
if !db_to_drop.starts_with("wm_fork_") {
errors.push(format!(
@@ -1427,7 +1433,64 @@ pub async fn drop_forked_datatable_databases(
));
continue;
}
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
// The fork's own entry is what is going away; anything else still reaching the copy,
// a child fork's pointer at this entry included, keeps it. The lock keeps a child fork
// from gaining such a pointer before the drop.
let dropped = async {
let mut tx = db.begin().await?;
// The three locks a settings save takes, in its order: this workspace's data
// tables, its settings row, and the database itself. Without them a save could
// rename this entry, or point another one here, either side of the check below.
windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?;
sqlx::query("SELECT 1 FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE")
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
windmill_common::datatable_roles::lock_instance_databases_governance(
&mut tx,
[db_to_drop.as_str()],
)
.await?;
if database.resource_type
== windmill_common::workspaces::DataTableCatalogResourceType::ExternalInstance
{
windmill_common::external_instance_pg::drop_external_instance_database_unchecked(
&db,
db_to_drop,
Some((&w_id, dt_name)),
)
.await?;
} else {
let uses = windmill_common::workspaces::managed_database_uses(
&mut tx,
windmill_common::workspaces::DataTableCatalogResourceType::Instance,
db_to_drop,
Some((&w_id, dt_name)),
)
.await?;
if !uses.is_empty() {
return Err(Error::BadRequest(format!(
"it is still used by {}",
uses.join(", ")
)));
}
windmill_common::drop_custom_instance_database(&db, db_to_drop).await?;
}
// The entry goes with the database: a fork this one is cloned into afterwards must
// not inherit a pointer at a data table whose database is gone.
sqlx::query(
"UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2]
WHERE workspace_id = $1",
)
.bind(&w_id)
.bind(dt_name)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok::<_, Error>(())
}
.await;
if let Err(e) = dropped {
errors.push(format!(
"Could not drop instance database '{}' for datatable://{}: {}",
db_to_drop, dt_name, e
+541 -164
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.815.0
version: 1.814.0
title: Windmill API
contact:
@@ -529,30 +529,6 @@ paths:
responses:
"302":
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
post:
security: []
summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go
operationId: confirmLoginLink
tags:
- user
parameters:
- name: token
in: path
required: true
schema:
type: string
responses:
"200":
description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown
content:
application/json:
schema:
type: object
required:
- location
properties:
location:
type: string
/auth/reset_password:
post:
@@ -1596,6 +1572,104 @@ paths:
schema:
type: object
/settings/external_instance_pg/status:
get:
summary: Returns whether the external instance cluster is configured and how its last setup went
operationId: getExternalInstancePgStatus
tags:
- setting
responses:
"200":
description: external instance cluster status
content:
application/json:
schema:
$ref: "#/components/schemas/ExternalInstancePgStatus"
/settings/external_instance_pg/setup:
post:
summary: Sets up the external instance cluster with its saved admin login, optionally rotating the passwords Windmill manages on it (enterprise edition only)
operationId: setupExternalInstancePg
tags:
- setting
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
rotate_passwords:
type: boolean
responses:
"200":
description: the setup report, also stored as the last setup
content:
application/json:
schema:
$ref: "#/components/schemas/ExternalInstancePgSetupReport"
/settings/external_instance_pg/databases:
get:
summary: Lists the databases Windmill created on the external instance cluster, with the workspaces whose data tables use each
operationId: listExternalInstancePgDatabases
tags:
- setting
responses:
"200":
description: databases by name
content:
application/json:
schema:
type: object
additionalProperties:
$ref: "#/components/schemas/CustomInstanceDb"
/settings/external_instance_pg/databases/{name}:
post:
summary: Creates a database on the external instance cluster (enterprise edition only)
operationId: createExternalInstancePgDatabase
tags:
- setting
parameters:
- name: name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
tag:
$ref: "#/components/schemas/CustomInstanceDbTag"
responses:
"200":
description: database created
content:
application/json:
schema: {}
delete:
summary: Drops a database Windmill created on the external instance cluster, refused while a data table uses it
operationId: dropExternalInstancePgDatabase
tags:
- setting
parameters:
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: database dropped
content:
application/json:
schema: {}
/settings/list_custom_instance_pg_databases:
post:
summary: Returns the set-up statuses of custom instance pg databases
@@ -4090,13 +4164,9 @@ paths:
guest_access_enabled:
type: boolean
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
required:
- workspace_id
- guest_access_enabled
- add_admins_and_developers_to_forks
/w/{workspace}/workspaces/get_settings:
get:
@@ -4187,9 +4257,6 @@ paths:
guest_jwt_jwks_url:
type: string
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -5261,7 +5328,7 @@ paths:
type: string
resource_type:
type: string
enum: [postgres, instance]
enum: [postgres, instance, external_instance]
resource_path:
type: string
governing_workspace_id:
@@ -5325,6 +5392,97 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/datatable_acl/{datatable_name}:
get:
summary: read the owner and grants of an instance data table's database, schema or table
operationId: getDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
- name: kind
in: query
required: true
schema:
type: string
enum: [database, schema, table]
- name: schema
in: query
required: false
schema:
type: string
- name: table
in: query
required: false
schema:
type: string
responses:
"200":
description: owner and grants
content:
application/json:
schema:
$ref: "#/components/schemas/DatatableAclInfo"
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan:
post:
summary: preview the SQL an ownership or grant change would run (data table administrators only)
operationId: planDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AclChangeRequest"
responses:
"200":
description: statements that would run, in a single transaction
content:
application/json:
schema:
$ref: "#/components/schemas/AclPlan"
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply:
post:
summary: run an ownership or grant change exactly as planned (data table administrators only)
operationId: applyDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AclChangeRequest"
responses:
"200":
description: change applied
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
get:
summary: list the data table roles the caller may connect as
@@ -5428,6 +5586,21 @@ paths:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: query
description: list only this data table; each listed data table opens a connection to its database
schema:
type: string
- name: role_for
in: query
description: the data table `role` applies to; every other one is listed as its default role
schema:
type: string
- name: role
in: query
description: the role to list `role_for` as; refused, in that entry's `error`, if the caller may not use it
schema:
type: string
responses:
"200":
description: table metadata of all datatables
@@ -5461,6 +5634,11 @@ paths:
required: true
schema:
type: string
- name: role
in: query
description: the data table role to read the table as; defaults to the data table's default role
schema:
type: string
responses:
"200":
description: schema of one datatable table
@@ -6320,39 +6498,6 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks:
post:
summary: choose whether new forks of this workspace start with its admins and developers
description: >-
When on, every fork created from this workspace gets the workspace's admins and
developers as members, with the role they hold here; operators, disabled users and
service accounts are left out. The setting is copied into each fork, so forks of a
fork follow it too. Off by default. Workspace-admin gated.
operationId: editAddAdminsAndDevelopersToForks
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Whether new forks start with this workspace's admins and developers
required: true
content:
application/json:
schema:
type: object
properties:
add_admins_and_developers_to_forks:
type: boolean
required:
- add_admins_and_developers_to_forks
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_jwt_key:
post:
summary: set the key guest JWTs are verified against for this workspace
@@ -6817,7 +6962,7 @@ paths:
type: string
expires_in_s:
type: integer
description: link lifetime in seconds, at most 7200 (default 600)
description: link lifetime in seconds, at most 900 (default 600)
rd:
type: string
description: same-origin path the browser lands on after login (default /user/workspaces)
@@ -6827,12 +6972,6 @@ paths:
mint only while the account still has this login type (for example
pending_oauth), so a link stops working once the owner has set a password
or signed in with a provider
confirm:
type: boolean
description: >-
return a /user/login_link page that signs in only when its button is
clicked, instead of a link spent by opening it; set it for links sent by
email, which mail scanners open on delivery (default false)
responses:
"201":
description: login link minted
@@ -10392,49 +10531,8 @@ paths:
current_timestamp:
type: string
format: date-time
path:
type: string
description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to."
required: [status, current_timestamp]
/w/{workspace}/drafts/move/{kind}/{path}:
post:
summary: move the current user's draft-only item to another path
description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it.
operationId: moveDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
description: script, flow, app or raw_app only.
schema:
type: string
enum: [script, flow, app, raw_app]
- $ref: "#/components/parameters/ScriptPath"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
new_path:
type: string
summary:
type: string
description: Also restate the draft's summary.
required: [new_path]
responses:
"200":
description: move result
content:
text/plain:
schema:
type: string
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
post:
summary: resolve a legacy (workspace-level) draft (admin only)
@@ -10945,8 +11043,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: script history
@@ -12352,8 +12448,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
tags:
- flow
responses:
@@ -14232,8 +14326,6 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: app history
@@ -14501,9 +14593,9 @@ paths:
"200":
description: app updated
content:
application/json:
text/plain:
schema:
$ref: "#/components/schemas/AppDeployed"
type: string
/w/{workspace}/apps/create_raw_source:
post:
@@ -14643,9 +14735,9 @@ paths:
"200":
description: app updated
content:
application/json:
text/plain:
schema:
$ref: "#/components/schemas/AppDeployed"
type: string
/w/{workspace}/apps/update_raw/{path}:
post:
@@ -14699,9 +14791,9 @@ paths:
"200":
description: app updated
content:
application/json:
text/plain:
schema:
$ref: "#/components/schemas/AppDeployed"
type: string
/w/{workspace}/apps/custom_path_exists/{custom_path}:
get:
@@ -27749,13 +27841,6 @@ components:
draft_saved_at:
type: string
format: date-time
draft_base:
type: string
description: |
The deployed version the draft forked from, as text whatever the
kind (script hash, flow version id, app version id). Compare to the
deployed head to tell a draft that is behind. Absent when there is
no draft or it was never forked from a deploy.
no_deployed:
type: boolean
draft:
@@ -29651,11 +29736,6 @@ components:
type: string
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- script_hash
@@ -33718,6 +33798,44 @@ components:
- ducklake
- datatable
ExternalInstancePgSetupStep:
type: object
required: [name, status, message]
properties:
name:
type: string
status:
type: string
enum: [ok, warning, error]
message:
type: string
ExternalInstancePgSetupReport:
type: object
required: [success, finished_at, steps]
properties:
success:
type: boolean
description: no step failed; warnings leave it true
finished_at:
type: string
format: date-time
steps:
type: array
items:
$ref: "#/components/schemas/ExternalInstancePgSetupStep"
ExternalInstancePgStatus:
type: object
required: [configured, database_count]
properties:
configured:
type: boolean
database_count:
type: integer
last_setup:
$ref: "#/components/schemas/ExternalInstancePgSetupReport"
InstanceDatatableRole:
type: object
required: [id, name, enabled]
@@ -33778,6 +33896,255 @@ components:
datatable:
type: string
AclTarget:
description: what access is read or changed on
oneOf:
- $ref: "#/components/schemas/AclTargetDatabase"
- $ref: "#/components/schemas/AclTargetSchema"
- $ref: "#/components/schemas/AclTargetTable"
discriminator:
propertyName: kind
mapping:
database: "#/components/schemas/AclTargetDatabase"
schema: "#/components/schemas/AclTargetSchema"
table: "#/components/schemas/AclTargetTable"
AclTargetDatabase:
type: object
required: [kind]
properties:
kind:
type: string
enum: [database]
AclTargetSchema:
type: object
required: [kind, schema]
properties:
kind:
type: string
enum: [schema]
schema:
type: string
AclTargetTable:
type: object
required: [kind, schema, table]
properties:
kind:
type: string
enum: [table]
schema:
type: string
table:
type: string
AclChange:
description: one change to plan or apply
oneOf:
- $ref: "#/components/schemas/AclChangeSetOwner"
- $ref: "#/components/schemas/AclChangeGrant"
- $ref: "#/components/schemas/AclChangeRevoke"
discriminator:
propertyName: type
mapping:
set_owner: "#/components/schemas/AclChangeSetOwner"
grant: "#/components/schemas/AclChangeGrant"
revoke: "#/components/schemas/AclChangeRevoke"
AclChangeSetOwner:
type: object
description: >-
hands the target to role — for a schema, with everything already in it but an extension's
members, which stay with the extension
required: [type, role]
properties:
type:
type: string
enum: [set_owner]
role:
type: string
description: a data table role of the instance, or admin
AclChangeGrant:
type: object
required: [type, role, privileges, scope]
properties:
type:
type: string
enum: [grant]
role:
type: string
description: a data table role of the instance, or admin
privileges:
type: array
items:
type: string
scope:
$ref: "#/components/schemas/AclGrantScope"
AclChangeRevoke:
type: object
required: [type, role, privileges, scope]
properties:
type:
type: string
enum: [revoke]
role:
type: string
description: a data table role of the instance, other than admin
privileges:
type: array
items:
type: string
scope:
$ref: "#/components/schemas/AclGrantScope"
objects:
type: array
description: >-
objects inside the target the revoke covers, empty for the target itself. Only with the
target scope; a revoke on all objects of a kind is refused, since it cannot say which
grants it takes back.
items:
$ref: "#/components/schemas/AclObject"
AclGrantScope:
type: string
enum:
[
target,
all_tables,
all_sequences,
all_functions,
future_tables,
future_sequences,
future_functions,
]
AclChangeRequest:
type: object
required: [target, change]
properties:
target:
$ref: "#/components/schemas/AclTarget"
change:
$ref: "#/components/schemas/AclChange"
statements:
type: array
description: >-
The statements the plan showed. Required to apply, which plans again and refuses if
the result differs.
items:
type: string
AclPlan:
type: object
required: [statements, warnings]
properties:
statements:
type: array
items:
type: string
warnings:
type: array
items:
type: string
AclObject:
type: object
required: [name, kind]
properties:
name:
type: string
kind:
type: string
description: >-
TABLE, SEQUENCE, FUNCTION, PROCEDURE or TYPE — what the object is. A revoke turns it
into the keyword it takes, ROUTINE for both routine kinds; a type's grants are read
only.
args:
type: string
description: identity arguments of a routine, which is what tells two of the same name apart
AclGrant:
type: object
required: [grantee, privileges, sources]
properties:
grantee:
type: string
privileges:
type: array
items:
type: string
object:
$ref: "#/components/schemas/AclObject"
future:
type: string
description: >-
set for a default privilege, naming the kind of object it covers (TABLES, SEQUENCES,
FUNCTIONS, TYPES, or SCHEMAS). On a schema, the defaults set in that schema; on the
database, the ones set database-wide, which apply in every schema and which no schema's
own defaults take back.
sources:
type: array
description: >-
the roles the grant comes from, each once — who granted it, or for a default privilege
the role whose future objects it covers. A revoke of some of the grant's privileges
takes them back from every source that gave them.
items:
$ref: "#/components/schemas/AclSource"
AclSource:
type: object
required: [role, privileges, reachable]
properties:
role:
type: string
privileges:
type: array
description: >-
what role gave of the grant's privileges. A revoke is held back only by a source out of
reach that gave some of what it takes back.
items:
type: string
reachable:
type: boolean
description: >-
whether the data table's connection can take back what role gave. On an object that is
the owner, when the connection acts for the owner, and otherwise the connection itself;
for a default privilege, a creating role the connection acts for. What a source out of
reach gave is not revocable from here; privileges only other sources gave still are.
DatatableAclInfo:
type: object
required: [owner, roles, editable, supports_maintain, dbname, grants, children]
properties:
owner:
type: string
roles:
type: array
description: the roles a change may name; empty unless the caller may change anything
items:
type: string
editable:
type: boolean
description: whether the caller may plan and apply changes
supports_maintain:
type: boolean
description: whether the server is Postgres 17+, which added the MAINTAIN table privilege
dbname:
type: string
description: the database the target lives in
grants:
type: array
items:
$ref: "#/components/schemas/AclGrant"
children:
type: array
description: a database's schemas, or a schema's tables
items:
type: string
CustomInstanceDb:
type: object
required:
@@ -33801,7 +34168,10 @@ components:
type: array
items:
type: string
description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted.
description: Workspaces that reference this database through a ducklake catalog or a datatable database of the kind being listed — 'instance' for the instance databases endpoint, 'external_instance' for the external cluster one. Computed at request time, not persisted, and only returned to superadmins.
workspace_id:
type: string
description: The workspace a member created this database for as a fork copy. Only that workspace can import into it or point a fork at it.
NewSqsTrigger:
type: object
@@ -34860,9 +35230,6 @@ components:
nullable: true
disabled:
type: boolean
is_service_account:
type: boolean
description: Whether this membership is a service account.
required:
- id
- name
@@ -35552,20 +35919,6 @@ components:
- raw_app
AppDeployed:
type: object
description: "What a deploy of an existing app answers with. `version` is the one this call wrote, which is what an editor pins as the fork base of the draft it starts next: reading the head back afterwards cannot tell it from a deploy that landed beside it. A metadata-only update writes none and reports the head it kept."
properties:
path:
type: string
description: Where the app now lives, which differs from the request path on a rename.
version:
type: integer
format: int64
required:
- path
- version
AppHistory:
type: object
properties:
@@ -35573,11 +35926,6 @@ components:
type: integer
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- version
@@ -35647,8 +35995,6 @@ components:
format: date-time
deployment_msg:
type: string
created_by:
type: string
required:
- id
- created_at
@@ -35862,6 +36208,7 @@ components:
enum:
- postgresql
- instance
- external_instance
resource_path:
type: string
required:
@@ -35945,7 +36292,17 @@ components:
DataTableTables:
type: object
required: [datatable_name, schemas]
required:
[
datatable_name,
schemas,
instance,
permissioned,
usable_roles,
default_role,
can_create_schema,
creatable_schemas,
]
properties:
datatable_name:
type: string
@@ -35958,6 +36315,26 @@ components:
type: string
error:
type: string
instance:
type: boolean
description: on the instance database, the only kind that can be under roles or have its access edited
permissioned:
type: boolean
usable_roles:
type: array
description: the roles the caller may connect as, by name; empty when not under roles
items:
type: string
default_role:
type: string
can_create_schema:
type: boolean
description: whether the role the listing connected as may create schemas
creatable_schemas:
type: array
description: the schemas the role the listing connected as may create in
items:
type: string
DataTableTableSchema:
type: object
+17 -91
View File
@@ -69,8 +69,8 @@ use windmill_common::{
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
strip_json_nul, Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
@@ -274,12 +274,6 @@ pub struct AppHistory {
pub version: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
/// Who deployed this version, and when — the diff's version picker names them so
/// a reader can tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
@@ -573,17 +567,6 @@ pub struct CreateApp {
pub skip_draft_deletion: Option<bool>,
}
/// What a deploy of an existing app answers with. `version` is the one this call wrote,
/// which is what an editor pins as the fork base of the draft it starts next: reading the
/// head back afterwards cannot tell it from a deploy that landed beside it. A
/// metadata-only update writes none and reports the head it kept.
#[derive(Serialize)]
pub struct AppDeployed {
/// Where the app now lives, which differs from the request path on a rename.
pub path: String,
pub version: i64,
}
#[derive(Serialize, Deserialize)]
pub struct EditApp {
pub path: Option<String>,
@@ -1237,42 +1220,18 @@ async fn get_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<AppHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", &path))?;
// Unasked-for, this listing stays whole: the deployment-history panel reads it
// without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
// Newest first in deployed order, which is a version's position in `app.versions` and
// not its `created_at`: the latter is the deploying transaction's start time, so two
// that overlap can carry it in the opposite order from the one they landed in. A row
// outside the array never sat in that sequence, so it sorts after the ones that did.
// Paging happens before the metadata joins, so a page costs its own rows.
let query_result = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
av.created_by as created_by, av.created_at as created_at
FROM app a
JOIN LATERAL (
SELECT av2.id, COALESCE(v.ord, 0) AS ord
FROM app_version av2
LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id
WHERE av2.app_id = a.id
ORDER BY ord DESC, av2.id DESC
LIMIT $3 OFFSET $4
) page ON TRUE
JOIN app_version av ON av.id = page.id
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2
ORDER BY page.ord DESC",
ORDER BY created_at DESC",
w_id,
path,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
).fetch_all(&mut *tx).await?;
tx.commit().await?;
let result: Vec<AppHistory> = query_result
@@ -1281,8 +1240,6 @@ async fn get_app_history(
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
created_by: Some(row.created_by),
created_at: Some(row.created_at),
})
.collect();
return Ok(Json(result));
@@ -1296,22 +1253,14 @@ async fn get_latest_version(
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
// The head is the tail of `app.versions` — the version the runtime serves. Deploys
// append to it under the app row's lock, whereas `app_version.created_at` is the
// deploying transaction's start time, so two that overlap can carry it in either
// order and the newest timestamp is then not the one that landed last.
let row = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
av.created_by as created_by, av.created_at as created_at
FROM app a JOIN app_version av
ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2",
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2
ORDER BY created_at DESC",
w_id,
path,
)
.fetch_optional(&mut *tx)
.await?;
).fetch_optional(&mut *tx).await?;
tx.commit().await?;
if let Some(row) = row {
@@ -1319,8 +1268,6 @@ async fn get_latest_version(
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
created_by: Some(row.created_by),
created_at: Some(row.created_at),
};
return Ok(Json(Some(result)));
@@ -2644,14 +2591,6 @@ async fn create_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
&app.path,
None,
)
.await?;
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, custom_path, labels)
@@ -3008,7 +2947,7 @@ async fn update_app(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> JsonResult<AppDeployed> {
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3047,7 +2986,7 @@ async fn update_app(
},
);
Ok(Json(AppDeployed { path: npath, version: v_id }))
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
@@ -3061,7 +3000,7 @@ async fn update_app_raw_source(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> JsonResult<AppDeployed> {
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3159,7 +3098,7 @@ async fn update_app_raw_source(
},
);
Ok(Json(AppDeployed { path: npath, version: v_id }))
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
/// Whether the caller may create an app at `path` — asked of the database rather
@@ -3385,7 +3324,7 @@ async fn update_app_raw<'a>(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> JsonResult<AppDeployed> {
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3433,7 +3372,7 @@ async fn update_app_raw<'a>(
},
);
Ok(Json(AppDeployed { path: npath, version: v_id }))
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
// async fn create_app_internal<'a>(
// authed: ApiAuthed,
@@ -3810,19 +3749,6 @@ async fn update_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
if npath != path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
path,
&npath,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
+22 -468
View File
@@ -19,7 +19,7 @@ use windmill_common::{
error::{Error, Result},
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
users::resolve_username_to_email,
utils::{check_proper_path, strip_json_nul},
utils::strip_json_nul,
variables::{build_crypt, encrypt},
};
@@ -29,7 +29,6 @@ pub fn workspaced_service() -> Router {
.route("/get/{kind}/{*path}", get(get_draft_for_user))
.route("/get_own/{kind}/{*path}", get(get_own_draft))
.route("/update/{kind}/{*path}", post(update_draft))
.route("/move/{kind}/{*path}", post(move_draft))
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
}
@@ -321,71 +320,12 @@ pub struct SaveDraftResponse {
/// On `saved`: when the change was applied (client remembers it as the
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
pub current_timestamp: chrono::DateTime<chrono::Utc>,
/// `saved` only: where the write landed. Differs from the URL path when the item
/// had moved away from it; the editor follows it there. Absent when a delete found
/// nothing to remove and the caller cannot read the path it moved to.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// The version a draft forked from, as the editors write it into `draft.value`.
/// Each kind names it differently and only one is ever set.
#[derive(Deserialize)]
struct DraftBaseVersion {
/// Scripts: hex-encoded script hash.
#[serde(default)]
parent_hash: Option<String>,
/// Flows: `flow_version.id`.
#[serde(default)]
version_id: Option<i64>,
/// Apps / raw apps: `app_version.id`.
#[serde(default)]
parent_version: Option<i64>,
}
impl DraftBaseVersion {
/// The base as the `draft.base` column stores it: one opaque text id whatever
/// the kind, so a reader compares it to the head without knowing the kind's
/// own field name or type.
fn as_text(&self, kind: UserDraftItemKind) -> Option<String> {
use UserDraftItemKind::*;
match kind {
Script => self.parent_hash.clone(),
Flow => self.version_id.map(|v| v.to_string()),
_ => self.parent_version.map(|v| v.to_string()),
}
}
}
/// The version this draft forked from, or `None` when it has none — a kind
/// that keeps no lineage, a malformed payload, or a draft that was never forked
/// from a deploy. Pure: no queries.
fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersion> {
use UserDraftItemKind::*;
if !matches!(kind, Script | Flow | App | RawApp) {
return None;
}
let base = serde_json::from_str::<DraftBaseVersion>(value).ok()?;
let has_base = match kind {
Script => base
.parent_hash
.as_deref()
.and_then(|h| windmill_common::scripts::to_i64(h).ok())
.is_some(),
Flow => base.version_id.is_some(),
_ => base.parent_version.is_some(),
};
has_base.then_some(base)
}
/// Apply the current user's draft: non-null `value` upserts, `null` (or
/// omitted) deletes. Either way, when the existing row is newer than
/// `last_sync` (and `force` is false) the op is skipped and the response is
/// `status = conflict` + the server's current timestamp.
///
/// A save addressed to a path its item moved away from lands where the move took
/// the drafts (`draft_move`), unless the caller still has a draft of their own at
/// that path. The response names where it landed.
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
/// upserts, `null` (or omitted) deletes. Either way, when the existing row is
/// newer than `last_sync` (and `force` is false) the op is skipped and the
/// response is `status = conflict` + the server's current timestamp.
async fn update_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -394,7 +334,7 @@ async fn update_draft(
Json(req): Json<SaveDraftRequest>,
) -> Result<Json<SaveDraftResponse>> {
let email = &authed.email;
let url_path = path.to_path();
let path = path.to_path();
// Saving a draft requires write permission on the underlying path. Deleting
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
// authed user, so they can always discard it even after losing write access
@@ -403,68 +343,11 @@ async fn update_draft(
// touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone
// — they keep the write gate.
let is_own_discard = req.value.is_none() && !req.legacy;
// `legacy` targets the workspace-level row and is delete-only: an upsert writes the
// caller's own row whatever it says. Every read of that rule goes through this.
let legacy_delete = req.value.is_none() && req.legacy;
// Whose row this write is for: the caller's, or the workspace-level one on a legacy
// DELETE (`legacy` is delete-only, so an upsert is the caller's own row either way).
// It picks both the record that applies — an item's move (`email IS NULL`) covers the
// legacy row too, since the same rename carried it — and the draft whose presence
// means this path is still the write's own.
let owner: Option<&str> = (!legacy_delete).then_some(email.as_str());
// The caller's own draft-only move outranks the move of the deployed item. Only the
// kinds whose value carries a deploy target are ever recorded as moved, so for the
// rest this would be a guaranteed-empty query on the autosave hot path.
let moved_to = match kind.typed_path_field() {
None => None,
Some(_) => {
sqlx::query_scalar!(
r#"SELECT m.new_path FROM draft_move m
WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3
AND (m.email IS NULL OR m.email = $4)
AND NOT EXISTS (
SELECT 1 FROM draft d
WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3
AND d.email IS NOT DISTINCT FROM $4
)
ORDER BY m.email IS NULL
LIMIT 1"#,
&w_id,
kind as UserDraftItemKind,
url_path,
owner,
)
.fetch_optional(&db)
.await?
}
};
let path: &str = moved_to.as_deref().unwrap_or(url_path);
// Everything past here writes, so the gate applies from here on. Answered
// without the path when the item moved: it may have gone somewhere the caller
// cannot see.
if !is_own_discard {
match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await {
// Naming the move is for whoever was editing the item: it tells them why a
// save they were already making stopped landing. Someone who cannot read the
// path they addressed gets the plain denial, or the wording itself would
// answer whether an item was moved away from a path they only guessed at.
Err(Error::NotAuthorized(e)) if moved_to.is_some() => {
return Err(Error::NotAuthorized(
match require_can_read_path(&authed, &user_db, &w_id, kind, url_path).await {
Ok(()) => {
"this draft's item was moved to a path you cannot write".to_string()
}
Err(_) => e,
},
));
}
other => other?,
}
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
}
let applied = if let Some(value) = &req.value {
let applied_at = if let Some(value) = &req.value {
// Secret variable values must never sit in `draft.value` in plaintext
// (see `encrypt_secret_variable_value`).
let serialized = if kind == UserDraftItemKind::Variable {
@@ -476,49 +359,19 @@ async fn update_draft(
// escape and later make any `->>`/`to_jsonb` extraction raise `22P05`.
// Strip it here so a NUL never reaches the column.
let serialized = strip_json_nul(&serialized);
// `base` is derived here from the value's per-kind field rather than sent
// by the client, so every writer (editors, chat, CLI) fills it the same way.
// Read from the sanitized text, which is what the value column gets: a NUL in the
// lineage field otherwise costs the draft its base (the hash no longer parses),
// leaving a draft that reads as up to date whatever the head is.
let base = draft_lineage(kind, serialized.as_ref()).and_then(|l| l.as_text(kind));
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
// when the row is newer than `last_sync`, RETURNING yields nothing.
// `created_at` defaults to `now()` but the migration overrides it ($8)
// so a migrated draft keeps its original age instead of jumping to top.
//
// A moved save ($10) carries the path keys its editor had before the move.
// One naming the path it addressed ($11) follows to where it landed; a draft
// already there keeps the keys the move gave it. A pre-sanitizer NUL escape
// in that draft makes `to_jsonb` raise, so it takes the incoming keys.
sqlx::query!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
VALUES ($1, $2, $3::text, $4,
CASE WHEN $10::bool
THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(
'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)
THEN to_jsonb($3::text) END,
'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)
THEN to_jsonb($3::text) END)))
ELSE $5::text::json
END,
COALESCE($8::timestamptz, now()), $9)
sqlx::query_scalar!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = CASE
WHEN NOT $10::bool
OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0
THEN EXCLUDED.value
ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')
|| jsonb_strip_nulls(jsonb_build_object(
'path', to_jsonb(draft.value) -> 'path',
'draft_path', to_jsonb(draft.value) -> 'draft_path')))
END,
created_at = EXCLUDED.created_at,
base = EXCLUDED.base
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
WHERE $7::bool = true
OR $6::timestamptz IS NULL
OR draft.created_at <= $6::timestamptz
RETURNING path, created_at"#,
RETURNING created_at"#,
&w_id,
email,
path,
@@ -527,13 +380,9 @@ async fn update_draft(
req.last_sync,
req.force,
req.created_at,
base.as_deref(),
moved_to.is_some(),
url_path,
)
.fetch_optional(&db)
.await?
.map(|r| (r.created_at, Some(r.path)))
} else {
// Delete, same conflict rule in the WHERE clause. Returns NULL when
// the row was too new (conflict) OR already absent (idempotent) —
@@ -554,20 +403,16 @@ async fn update_draft(
kind as UserDraftItemKind,
req.last_sync,
req.force,
legacy_delete,
req.legacy,
)
.fetch_optional(&db)
.await?
// Named for the same reason an upsert is: the editor that discarded is still on
// the path the item left, and reloading there would land on nothing.
.map(|ts| (ts, moved_to.clone()))
};
if let Some((ts, path)) = applied {
if let Some(ts) = applied_at {
return Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: ts,
path,
}));
}
@@ -583,7 +428,7 @@ async fn update_draft(
email,
path,
kind as UserDraftItemKind,
legacy_delete,
req.legacy,
)
.fetch_optional(&db)
.await?;
@@ -592,307 +437,20 @@ async fn update_draft(
Some(ts) => Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Conflict,
current_timestamp: ts,
path: None,
})),
// Delete + nothing-was-there ⇒ report success with server's NOW().
None => {
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
.fetch_one(&db)
.await?;
// A retry of a routed discard whose answer was lost lands here: the row is
// gone but the editor is still on the path the item left, so it needs the
// destination as much as the first attempt did. Unlike the arm above there is
// no deleted row proving the caller ever held that draft, and an own discard
// is not gated, so this names a path to someone who may have none of it: the
// read gate, which is what keeps a path from being disclosed elsewhere in this
// module. Without it, discarding at a guessed path reads `draft_move`.
let disclosed = match moved_to {
Some(dest) => {
match require_can_read_path(&authed, &user_db, &w_id, kind, &dest).await {
Ok(()) => Some(dest),
Err(Error::NotFound(_))
| Err(Error::NotAuthorized(_))
| Err(Error::BadRequest(_)) => None,
Err(e) => return Err(e),
}
}
None => None,
};
Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: now,
path: disclosed,
}))
}
}
}
#[derive(Deserialize)]
pub struct MoveDraftRequest {
pub new_path: String,
/// Also restate the draft's summary, so the same drawer that renames a
/// deployed item can retitle a draft-only one.
#[serde(default)]
pub summary: Option<String>,
}
/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but
/// its draft row, so moving it is a rewrite of that row's path plus both path
/// keys inside its value — there is no deployed row, schedule or trigger to
/// cascade to.
///
/// The owner's own open editor follows: its next save, still addressed to the old
/// path, lands at the new one through the move record, and it is told where.
///
/// Scoped to the caller's own row on purpose: two users can each have a draft
/// at the same never-deployed path, and those are two separate items.
///
/// A DEPLOYED item must move through its own deploy endpoint instead, which
/// cascades everything that references the path and carries every draft along.
async fn move_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
Json(req): Json<MoveDraftRequest>,
) -> Result<String> {
let path = path.to_path();
let new_path = req.new_path.as_str();
// Only the full-page editor kinds, which is exactly the set that has a typed
// path to rewrite. Reading the movable set off the same mapping the rewrite
// uses keeps them from drifting apart: a resource, a variable or a trigger
// keeps its deploy target in `value.path` with no editor to stage a rename,
// so moving one would leave the real target naming the old location and the
// next deploy would recreate it where it came from.
let (Some(typed_field), Some(mirror_field)) =
(kind.typed_path_field(), kind.mirror_path_field())
else {
return Err(Error::BadRequest(format!(
"moving a {kind:?} draft is not supported — only scripts, flows and apps"
)));
};
// Validate before authorizing: `require_can_write_path` is not a format check
// (an admin returns immediately, and a user returns early inside their own
// namespace), so without this a malformed path is stored as-is, and an over-long
// or NUL-bearing one reaches Postgres as a raw server error. The source is a URL
// segment and arrives decoded, so it needs the same check as the destination.
check_proper_path(path)?;
check_proper_path(new_path)?;
// A summary-only edit is a legitimate use of this endpoint: the drawer edits
// both fields, and for a draft-only script the path it posts back is the row
// path unchanged (`list_scripts` only reports `draft_path` when it differs).
// Returning early on the path alone would drop the new summary silently.
if new_path == path && req.summary.is_none() {
return Ok("unchanged".to_string());
}
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
if new_path != path {
require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?;
}
if let Some(table) = kind.deployed_table() {
// `table` is from the closed `deployed_table()` enum, never user input.
// Archived and soft-deleted rows keep sitting at their path — a script
// move archives its parent in place — so an existence check that counted
// them would refuse a move away from, or into, a path nothing occupies.
// `create_script_internal` resolves its own path clashes the same way.
let archived_filter = if table == "script" {
" AND NOT archived AND NOT deleted"
} else {
""
};
let query = format!(
"SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2{archived_filter} LIMIT 1"
);
let mut tx = user_db.clone().begin(&authed).await?;
let deployed_at_old = sqlx::query_scalar::<_, i32>(&query)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let deployed_at_new = sqlx::query_scalar::<_, i32>(&query)
.bind(new_path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if deployed_at_old.is_some() {
return Err(Error::BadRequest(format!(
"'{path}' is deployed — move it from its editor so schedules and triggers follow"
)));
}
if deployed_at_new.is_some() {
return Err(Error::BadRequest(format!(
"'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it"
)));
}
}
// A NUL in the summary reaches Postgres as a raw byte and fails the statement with
// an encoding error, so it is dropped here the way `strip_json_nul` drops one from a
// draft value. `json` cannot store it either.
let summary = req.summary.as_ref().map(|s| s.replace('\0', ""));
// A classic app and a raw app share the `app` table, so a draft of either kind
// occupies the destination for both: deploying there deletes the caller's drafts
// of both kinds, taking the item that lost the collision with it.
let collision_typs: Vec<&str> = match kind {
UserDraftItemKind::App | UserDraftItemKind::RawApp => vec![
UserDraftItemKind::App.as_str(),
UserDraftItemKind::RawApp.as_str(),
],
_ => vec![kind.as_str()],
};
// One transaction with the move record, so a save addressed to the old path
// never sees the row gone without knowing where it went.
let mut tx = db.begin().await?;
let moved = sqlx::query_scalar!(
r#"UPDATE draft
SET path = $3,
-- Both path keys, not just the typed one: the editors mirror the
-- typed path into the other while it differs from the row's path,
-- and the loaders prefer the mirror left naming the old location
-- it un-does this move on the next save. `create_missing = false`
-- on both, so a draft carrying only one keeps only one.
value = to_json(
jsonb_set(
jsonb_set(
CASE WHEN $7::text IS NULL THEN to_jsonb(value)
ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))
END,
ARRAY[$5::text], to_jsonb($3::text), false
),
ARRAY[$8::text], to_jsonb($3::text), false
)
)
WHERE workspace_id = $1
AND path = $2
AND typ = $4
AND email = $6
-- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded
-- here so the statement can't 500; reported below instead. Unlike the
-- passive carry, rewriting the value IS this operation, so skipping it
-- silently would move the row and leave its typed path stale.
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0
-- Skipped on a summary-only edit, where the "target" row is this
-- row and the guard would refuse the update against itself.
AND ($2 = $3 OR NOT EXISTS (
SELECT 1 FROM draft o
WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])
-- Of this kind only the caller's own row and the legacy one collide:
-- teammates' drafts of one item share its path by design, but a deploy
-- there wipes those two together, so a second would discard edits the
-- caller never saw. The other app kind is a different item on the same
-- deployed path, so it collides whoever owns it.
AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)
))
RETURNING id"#,
&w_id,
path,
new_path,
kind as UserDraftItemKind,
typed_field,
&authed.email,
summary,
mirror_field,
&collision_typs as &[&str],
)
.fetch_optional(&mut *tx)
.await?;
if moved.is_some() && new_path != path {
windmill_common::user_drafts::record_draft_move(
&mut tx,
&w_id,
&[kind],
path,
new_path,
Some(&authed.email),
)
.await?;
}
tx.commit().await?;
if moved.is_none() {
let row = sqlx::query!(
r#"SELECT
-- The guard's own predicate, ordered own row, then legacy, then another
-- user's other-kind row: each needs a different remedy, and a bare
-- LIMIT 1 would name an arbitrary one.
(SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[])
AND (typ <> $2 OR email = $4 OR email IS NULL)
ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END
LIMIT 1) as "at_target",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email IS NULL) as "at_target_legacy!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email = $4
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
) as "poisoned!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email IS NULL) as "legacy!" "#,
&w_id,
kind as UserDraftItemKind,
new_path,
&authed.email,
path,
&collision_typs as &[&str],
)
.fetch_one(&db)
.await?;
return Err(Error::BadRequest(if row.poisoned {
// This endpoint also serves a summary-only edit, so name the operation
// the caller actually asked for rather than always saying "moved".
let attempted = if new_path == path { "updated" } else { "moved" };
format!(
"'{path}' contains a NUL character and predates the sanitizer, so it cannot be \
{attempted}. Reopen it, re-save to rewrite it cleanly, then retry."
)
} else if row.legacy {
// The home list synthesizes a draft-only row for the legacy draft with the
// caller's own name on it, so this is reachable from the row menu. Only an
// admin can claim or discard that row, and only from the drafts page.
format!(
"'{path}' is a legacy workspace draft with no owner, so it cannot be moved. \
A workspace admin can claim or discard it on the Review & deploy drafts page."
)
} else if let Some(occupant) = row.at_target.filter(|_| new_path != path) {
// Naming the kind matters for the app pair: a classic-app draft refusing a
// raw-app move is invisible in the raw-app list the caller is looking at.
let occupant = occupant.replace('_', " ");
if row.at_target_own {
format!("You already have a draft at '{new_path}' ({occupant})")
} else if row.at_target_legacy {
// An ownerless row the caller cannot clear themselves, so send them to
// the one place it can be resolved rather than to "discard your draft".
format!(
"A legacy workspace draft with no owner is already at '{new_path}' \
({occupant}). A workspace admin can claim or discard it on the Review & \
deploy drafts page."
)
} else {
// The other app kind, owned by someone else: one deployed path cannot hold
// both, so this is the other item's path, not a teammate's copy of this one.
format!(
"'{new_path}' holds another user's {occupant} draft, and an app and a raw \
app cannot share a path. Pick another path, or ask them to move or \
discard theirs."
)
}
} else {
format!("You have no draft at '{path}'")
}));
}
if new_path == path {
return Ok(format!("updated draft {path}"));
}
Ok(format!("moved draft {path} to {new_path}"))
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum MigrateLegacyDraftAction {
@@ -946,12 +504,12 @@ async fn migrate_legacy_draft(
r#"WITH legacy AS (
DELETE FROM draft
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
RETURNING value, base
RETURNING value
)
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
SELECT $1, $4, $2, $3, value, now() FROM legacy
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
DO UPDATE SET value = EXCLUDED.value, created_at = now()
RETURNING 1 as "one!""#,
&w_id,
path,
@@ -1209,8 +767,8 @@ async fn require_can_write_path(
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers:
/// 1. admin → always.
/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any
/// folder in `authed.folders` (the precomputed read set, with groups + direct
/// 2. Path-prefix match against own `u/{username}` or any folder in
/// `authed.folders` (the precomputed read set, with groups + direct
/// grants already factored in).
/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level
/// extra_perms grants that bypass folder/owner checks.
@@ -1235,10 +793,6 @@ async fn require_can_read_path(
if parts.len() >= 2 {
match parts[0] {
"u" if parts[1] == authed.username => return Ok(()),
// As `require_can_write_path` and the `see_member` RLS policy read it: a
// draft-only `g/` path has no row for the probe below to fall back on, so
// without this a member cannot see a draft their group owns.
"g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()),
"f" => {
let folder = parts[1];
if authed.folders.iter().any(|(name, _, _)| name == folder) {
+6 -1
View File
@@ -8723,7 +8723,12 @@ fn register_potential_assets_on_inline_execution(
.as_ref()
.and_then(|args| args.get("database"))
.map(|v| v.get().trim_matches('"'))
.and_then(|dt| dt.strip_prefix("datatable://"));
.and_then(|dt| dt.strip_prefix("datatable://"))
// `?role=` picks the connection, not the data table. Anything else after a `?` may be
// part of a name stored before names were restricted, so it stays.
.map(|dt| {
windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name)
});
if let Some(datatable) = datatable {
let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap();
let (schema, content) = if let Some(captures) = re.captures(&preview.content) {

Some files were not shown because too many files have changed in this diff Show More