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
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
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
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
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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
`?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
`-- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
* feat: list flows that link a saved agent and flag broken agent links
* feat: rename saved agents from the agent editor and repoint the flow
* fix: show an unreadable linked agent as not accessible, not missing
* fix: address review nits on agent rename and missing-agent state
* fix: open content search above modals and keep Escape for it
* fix: register content search on the opener's overlay stack
* docs: scope the global search z-index comment to the bases it clears
* refactor: show linked agents' rename warning as for scripts and flows
* fix: keep the failed-lookup rename warning to resources
* feat: delete a browser's copy of an AI session past its workspace retention
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: tell the AI session retention only to a member who can reach the workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: keep the retention sweep's design narrative in the docs, not the code
* fix: give the session retention its own route, leaving the status contract alone
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: shorten the retention route comment to its constraints
* docs: name the two clocks in the retention setting, and the deploy window
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: retention for AI sessions, swept on the object store and in the browser
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: make the retention sweeps retryable and safe against pushes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: spare other tabs' sessions, reclaim abandoned split pushes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: sweep under an exclusive session lock, keep the captured user
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: say a tab selecting a session mid-sweep is not held back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: sweep local sessions only while no other tab has them loaded
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: one tab sweeps at a time, and keeps the switched user's hold
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: push the fallback session again before the rotation assertions
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: keep retention server-side here, move the browser sweep out
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: the retention setting no longer touches browser-local sessions
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat: instance object store as fallback for AI session backups
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: fence the instance store sweep by generation, name it by location
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: pin that an instance store location tells endpoints apart
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: show the instance storage fallback setting on while it is unset
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: check the generation fence queries at compile time
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: stop the instance storage fallback once the plan is Pro
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat: back AI sessions up to the workspace object storage
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp
* fix: bind the backup key to the user and pack pushes within the server caps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp
* fix: keep refused and unavailable marks, one mark per key, stream the flush
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp
* fix: settle only fully sent sessions, keep removals while backups are off, cap pull bodies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp
* fix: bound removal marks while backups are off and stale the sync rows instead of dropping them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: retry a lost lock, cap nested push lists and oversized pieces, drop a stale copy of a chat that outgrew the backup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: cap pieces per push, size requests in UTF-8, keep a move's removal for an off workspace, disclose the restore counter
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: file a move's removal only once the new copy landed, retire marks through the sync row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: keep a session marked while deletes are carried over, drop only gone sessions' marks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: record what a refused flush already stored, stop early when every mark is retired
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: restore past another workspace's removal mark, file a move's removal before its row, bound the first pulled session
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: re-key the backups on workspace key rotation, accept only base64 images, carry a delete on the sync row when its mark cannot be written
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: durable conditional re-key of session backups, re-push on a storage switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: fail a push the key rotated under, settle no session split across storages, narrow the re-key module
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: keep a delete filed during a push, bound the pull and re-key listings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: bound the session listing, mark the store's own user on a write that lands after a user switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: list sessions through per-session index markers, hold a session's parts back after a failed one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: record a rotation on every build, list a session only on the part that completes its push
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: leave an object larger than any push writes unread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: read each object against its listed size, carry a dirty mark that cannot be written on the sync row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: note the storages the re-key walk completed on, reach another user's rows on a failed mark, read a head at its cap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: read the replaced key under its row lock, carry a refused dirty bump on the sync row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: pull a session that outgrew one answer in pages, imported only whole
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: build a pull page from the smallest keys of the whole listing, stage each page as it lands
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: re-record a key rotated back to, admit earlier-page images, restage over a cut-short restore
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor: delete the backups on key rotation instead of re-keying them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: end a pull page before an object that grew since the listing, prune what a cut-short restore staged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: delete the backups before the key commits, skip a planted object whatever its listing says, lock a restore across tabs, prune stale artifact versions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: keep the backups under a prefix named by the key, delete the previous key's prefix after the commit
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: name the backup prefix by a generation the rotation bumps, never write an older record over a newer one on restore
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: retire a removal only against the storage holding the backup, restart a paged pull whose listing moved
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: fingerprint a pull page before reading it, answer the backup generation apart from the storage identity
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: answer needs_head for a headless session push, prune restaged pieces by id
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: serialize a session's push and removal, open whole pushes with the head, prune only own restores
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: require a head on a whole push, prune before the record lands, restore only under Web Locks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: incremental pushes ride on a listed session, removals wait for every storage holding a copy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: a whole push replaces the backup under a per-push token, a pull page is checked after its reads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: fingerprint a pull page by entity tag and version too
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: a moved session's removal mark names the storages holding the old copy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: restore a workspace family together, the newest copy of a moved session winning
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: name the marker by the session's move count, abort a family restore a listing failed in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: list the family again before a restored record lands, require the pull fingerprint
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: list the whole family once per restored workspace, off members included
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: a push split over parts, incremental too, unlists the session until its last part
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: refuse a partial push part that names no push
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: keep a refused bump for a session with no row yet, probe an off workspace again
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: backfill row-carried bumps after a reload, ask an off workspace again on a timer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: backfill a row for its bumps only when it carries some
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: a backfilled mark that cannot be written counts from the page's counter
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: say an off workspace is asked again, in the mirror's comments
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore: update ee-repo-ref to 1c1dab33563c4907aff8b0da825fb66db60af82a
This commit updates the EE repository reference after PR #796 was merged in windmill-ee-private.
Previous ee-repo-ref: 289b477ca3fc993da06ec09b11c8f55d5e4e39c1
New ee-repo-ref: 1c1dab33563c4907aff8b0da825fb66db60af82a
Automated by sync-ee-ref workflow.
* fix: unlist a session while an incremental push changes more than one object
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* feat: label resource types and integrations with hub display names
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: load hub integration names in the app and flow pickers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: load hub resource type names where drawers title a type
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: store resource type display names and drop the hardcoded list
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: leave display_name out of the fork comparison
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: ignore over-long synced display names, move name loaders
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: share the hub integration list cache, backfill admins only
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep a name over a nameless duplicate, retry failed hub reads
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(git-sync): run auto-pull as the admin who enabled it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(git-sync): audit the admin grant fork pulls make
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: bump ee ref for the post-commit fork grant audit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(git-sync): address review nits on the auto-pull stamp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: update ee-repo-ref to ccada062c072d7b74894b63863728fd1ef9bdffd
This commit updates the EE repository reference after PR #799 was merged in windmill-ee-private.
Previous ee-repo-ref: 7cee30f0cf12721cba551cd754dc817444810470
New ee-repo-ref: ccada062c072d7b74894b63863728fd1ef9bdffd
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
* feat: add per-route CORS origin allowlist for HTTP triggers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: fail closed on cold router cache and invalid origin input
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resolve CORS route from the decoded path like the request handler
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: add instance-wide default allowed origins for HTTP routes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: let non-superadmins read the default allowed origins setting
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: badge the advanced section when a route's origins are restricted
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: state inherited origins on the control and use one hint row
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: trim the origins tooltip and relabel the toggle when a default exists
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: keep the origins format hint visible until an entry is wrong
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: state the at-least-one requirement in the origins hint
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: import the origins validator in the trigger-http tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: make an empty allowlist deny rather than fall back to the default
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: address review nits on origin validation and the CORS editor
* fix: derive the origins error from the stored list and tighten host validation
* fix: parse real IPv6 hosts and refuse a newly emptied allowlist
* refactor: make origin validation advisory except for null and non-ascii
* feat: let an empty allowlist be saved as deny every origin
* docs: document the empty allowlist as deny every origin
* fix: bound allowlists, reject commas, and decide cors after the handler
* chore: revert unrelated rustfmt churn in windmill-common tests
* chore: revert unrelated rustfmt churn in windmill-common
* chore: drop the route types the cors restructure replaced
* fix: take the stricter cors decision from before and after the handler
* fix: strip runnable cors headers when the routers are unavailable
* docs: document the allowlist bounds in the openapi schema
* fix: let an unavailable cors read defer to one that resolved
* refactor: carry the resolved cors policy from the handler to the middleware
* docs: describe why an unavailable read fails closed on the paths that reach it
* fix: validate the default origins on the declarative settings path
* test: keep the webhook doc comment with the test it describes
* fix: warn on impossible schemes and ports, and validate the instance setting
* feat: treat an empty allowlist as unset at both levels
* perf: decode the cors path only when the fallback needs it
* docs: document the empty allowlist as unset in the api schema
* docs: describe an empty allowlist as unset in the frontend comments
* docs: say what a null allowlist resolves to, not what it meant before the default existed
* docs: state what the validator refuses and why methods stay broad
* feat: exempt static asset routes from the origin allowlist
* fix: hide the origin control for every static target, not just websites
* fix: exempt only static websites, not single-file static assets
* fix: warn on an unclosed ipv6 host in the origins advisory
* fix: require assets present, not just the static website flag
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>