Starting a new app while the role list loads saved no role, which the server
resolves to the data table's configured default — a role the caller may not be
allowed to use, where a moment later the picker would have chosen one they can.
Start waits for the list that answers for the data table selected.
The schemas and the right to create one get the same stamp the role list has. At
mount they are the initial value rather than an answer, and the effect read that
as "this role cannot create schemas", flipping the modal off `New` with nothing
to put it back; during a switch they are the previous data table's.
A role drop that fails names what it leaves behind whichever statement failed:
the config that stopped naming them has committed either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Switching the new-app picker to another data table leaves the previous one's
roles in hand until the request lands, so resetting against them chose that
table's default and recorded the choice as this table's — and a role name the two
share survived the switch. The list now says which data table it answers for, and
the picker waits for the one it asked about.
The fork dialog says "Not shared (permissions enabled)" where it used to say
"Keep original", which for a permissioned data table now means it is absent from
the fork. The listing carries that state so it cannot pair with another
workspace's data tables.
A failed drop names only the roles it actually tried; the ones the config named
again were skipped, and the caller could not tell them apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
A data table the fork shares rather than forks still points at the parent's
database, and its cloned permissions block froze the tenants at the moment of
the fork: the parent revoking one never reached the copy, so a member who forked
while authorized kept running as that role afterwards. Dropping the block instead
would hand the fork the data table's own connection, which owns everything — so a
permissioned data table is not shared into a fork at all.
A deferred drop that fails is the end of the line for those logins, since the
config that named them has committed and no later plan diffs against them. The
error says so, and names them.
The new-app picker resets its role when the data table changes rather than only
when the name is gone: two data tables can both define an `analyst`, and the name
surviving is not the role surviving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
The ordering landed on `global_offboard_preview`, which takes no locks at all;
`offboard_global_user`, which takes each workspace's settings row, still walked
its memberships in whatever order the query returned.
`read_datatable` and `connect_as_admin` say what they hand out and what a caller
owes them: the first returns the config with its generated role passwords, the
second the connection that owns every object in the data table's database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Every path that touches a data table's permissions or a principal its roles can
name takes the workspace settings row before it locks anything else, and a
transaction spanning workspaces takes them in `workspace_id` order. One lock
acquired first cannot deadlock, so the rule is stated once on the lock rather
than as an order each handler has to reproduce — the rename can write its thirty
tables in whatever order reads best, and the statements moved around to fake an
order move back.
What waits for the config to commit is now decided from the plan and checked per
role: a request that disables permissions still lists every role, so the set
built from it claimed them all and no drop waited at all. A role the committed
config no longer names is dropped whatever else changed meanwhile, where before
any unrelated edit — a tenant, the default role — made the whole cleanup stand
down and left the login behind with nothing that would ever remove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
A permissions save ran its whole plan against the data table's database before
writing the config, so an opt-out or a role removal that failed on the Windmill
side afterwards rolled the config back with the roles already gone and their
grants discarded. Only the creates and renames run there now; the drops run once
the config that no longer names them has committed, skipped if another save has
landed in between.
The rename's `group_` rewrite moves after the settings row for the same reason
the rest of it did: group deletion takes them in that order, and the two
overlapping was a deadlock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
`update_username_in_workpsace` locks one workspace's settings row but wrote
`usr` and `usr_to_group` across every workspace holding that name, so it waited
on principals another workspace's settings row guards — the deadlock the last
commit closed, one workspace over. Both writes are scoped now, and the rename
walks memberships in workspace order.
The deferred role drop holds the settings row while it runs, so its session
carries a statement timeout: the statements run on a database the workspace does
not control, and a lock held there would stall every save behind it.
`PgRoleInventory` is read by the enterprise planner alone, which a community
build compiles without.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Role names are generated from the data table's name, so one recreated under a
deleted table's name generates the same ones, and its save adopts them. The drop
planned for the deleted table would then take the live one's logins and grants.
It now runs under the settings row it deletes them for, and stands down when
that name is back.
The rename writes `usr` and `usr_to_group` after the settings row, like every
other path that frees or renames a principal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Postgres roles are cluster-wide, so two data tables that generate one name share
a login. The name's discriminator was four bytes, which is searchable rather
than merely unlucky: two data table names that collide were found by hand. It
is now sixteen, and a name that is already taken is refused unless this data
table owns the role (enterprise repo).
The database a data table's roles were created in is recorded with them and
checked where the connection is finally resolved. The guards that refuse a
resource edit read the config separately from the save that enables permissions,
and never expand a `$res:` or `$var:` that can repoint the resource just as
well — so they stay as early refusals while the check that holds is the one at
the point of use.
Dropping a deleted data table's roles now runs after the settings transaction
commits, planned before it: `DROP OWNED` discards their grants for good, and a
save that rolls back after that point would leave a data table naming logins
that no longer exist.
Leaving a workspace takes the tenant of the row it deletes rather than of the
cached identity, which a rename leaves stale, and group deletion takes the
settings row before the membership rows like every other path that frees a
principal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Removing a member takes both the workspace settings row (to drop the tenants
naming them) and their `usr` row. Two of those paths running at once took them
in opposite orders and deadlocked; every path now takes the settings row first.
The role picker in the data table explorer is the design system's badge rather
than a hand-rolled button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Three deletions bypassed the workspace-user cleanup and left `u/<username>` on
the roles it named: the superadmin's global delete, which frees the name in
every workspace at once, and both leave routes. A member invited into one of
those names afterwards inherited the roles.
The settings lock says what it hands back — the config as stored, generated
role passwords included — so it carries `_unchecked` and the contract that goes
with it: callers authorize the read, and redact before passing the value on.
The role save took an advisory lock of its own while principal cleanup took the
settings row, so the two never excluded each other: a save could persist a
permissions block it had computed before a group's deletion took that group off
it. The settings form had the same shape with no lock at all — it carries the
old permissions forward by construction.
They all take the settings row now, before reading and until they have written,
which is the lock the cleanups already used. One mechanism, so there is no
ordering to get wrong, and the advisory lock goes away with its cached query.
The tenant removal says what it does not do: it authorizes nothing, and the
rules differ per caller — a workspace admin for a user, the owner for a group
or folder, no identity for the system paths — so the name carries `_unchecked`
the way the resolution helpers next to it do.
* fix: record supplied script lock hashes so importers can skip relocking
Creating a script with a caller-supplied lock — a CLI push, a git-sync deploy,
any create carrying a lockfile — stored the lock on `script` but never wrote the
matching `lock_hash(workspace_id, path, hash_script(lock))` row. Only
worker-generated locks did.
`try_skip_relock` treats a missing hash for an imported script as changed, so no
importer of such a script could ever satisfy the skip predicate: every deploy of
it relocked every importer, forever.
The create transaction now records the hash for any lock it accepts, including
the empty one a codebase or a language with no lock generation carries — the
worker writes `hash_script("")` there, and a path going from a real lock to an
empty one has to stop matching what its importers recorded. Only a lock left to
a dependency job is skipped, because that job writes it.
A workspace clone now carries `lock_hash` too, without which every
dependency-map snapshot the clone later recorded held NULL and nothing in it
could ever skip. `dependency_map.imported_lockfile_hash` is deliberately not
copied: it records what an importer resolved against when it was last locked,
the clone runs READ COMMITTED, and a relock landing in the source between the
scripts being cloned and that statement would attach a hash the cloned
importer's lock was never resolved against — a hash older than the cloned
scripts costs one relock, a newer one skips a relock that was needed.
Lock generation is untouched, as is everything a relock does once it runs. The
only behavior that moves is which relocks are skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: narrow to the create-path lock hash
Drop the workspace-clone copy of lock_hash. It sits outside the reported
bug, and its double join over `script` can emit a path twice where two
versions are live, which the unique key on (workspace_id, path) then
rejects, failing the whole fork.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: restore the workspace-clone lock hash copy, guarded against fanout
A path can hold two live versions, and both joins match on path alone, so
the select can emit it four times against a primary key that admits one.
Every such row carries the single hash the path has, so ON CONFLICT DO
NOTHING settles it rather than aborting the fork.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: hash a clone's own locks rather than copying the source's rows
A source row is only as current as the last write to it, and a supplied
lock deployed before this was recorded leaves one naming a lock the path
no longer holds. Copying that into a fork hands an importer a hash it
never resolved against; hashing what the clone holds cannot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* test: pin the lock hash written on a no-op push
Removing that write leaves the assertion with no row, which is the state
a script deployed before this shipped would stay in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* refactor: share one lock hash writer between the create and clone paths
Both wrote the same upsert with different SQL. The existing writers fold
theirs into the statement that writes the lock itself, which is what keeps
the two consistent; these two have nothing to fold it into, so they take a
shared one instead. The clone walks its pages by path rather than listing
them first, dropping a query with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: stream a clone's locks rather than reading them in pages
script.lock is unbounded, so a page of them is bounded only by how many
it holds. Hashing each as it arrives keeps one in memory at a time and
lets the clone site collapse to a single call.
Also states on both writers that they check no access to the workspace
they write, which their callers are the ones to have established.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
* fix: make the lock hash writer safe to repeat and free when unchanged
A path given twice in one call would have Postgres reject the whole
statement, so the last hash for each wins. And recording a hash a path
already has cut a row version for nothing on every unchanged sync, which
is the mode the no-op push runs in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oct9a6SLEZvFyCQgHRBx
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A data table role names its tenants — `u/alice`, `g/devs`, `f/team` — and a
data table names its database by resource path. Both are strings that outlive
what they point at, and the flows that free those names left them behind:
- Deleting a workspace user, a group or a folder left its tenant on every role
it could run as. The name is free afterwards, so whoever takes it next
inherits the role without an admin granting it. All three now drop it in the
same transaction that removes the principal.
- Offboarding reassigns a user's resources to a folder without the data table
following, which both stops it resolving and frees the path it named — a new
resource there points it at another database with its roles intact. The
username rename had the same gap. Both move the config with the resource.
The walk both need is one function each now, with the table test that pins
which tenant forms it touches.
Saving permissions also takes a lock per data table, held from the read that
plans to the write that persists: two saves interleaved each planned against
what the other was leaving, and the one that persisted last stored roles the
other had already dropped. The ACL apply takes the same lock, since it reads
the same config and catalog.
`get_public_settings` is the one the logged-in layout calls for every user, and
it served the data table config as stored — generated role logins included. A
member who is a tenant of no role could read every role's password and connect
as it directly, which is the whole tenant model. It goes through the same
redaction as the admin settings and the tarball now.
Also, on the way there:
- The permissions drawer opened from a data table's row sent the role the
manager is connected as, which belongs to another data table: roles are
per data table, so a row under another one is read as its own default role.
- The grant repair on an instance database ran before `apply_datatable_acl`
authorized anything, so any member could drive that privileged connection
with a request about to be refused.
- That repair also assumed schema `public` exists, and failed whole where it
had been dropped.
The path in the config staying the same said nothing: a postgres resource is
editable in place, so its host, database or user could change underneath roles
whose logins and grants live in the database it used to name. The identity a
connection resolves to is what has to hold still while those roles exist; a
password rotation is not an identity change and stays allowed.
Also generalizes the admin guard: what 'admin' holds is what every role here
connects through, on the database and on schema public alike, so a revoke
naming it is refused wherever it is aimed.
Its roles live in the database it points at: the logins were created there and
every grant they hold is recorded there. Carried onto another database they
authenticate against a cluster that never heard of those grants. Opting out
first is what drops them from the database they belong to.
Also: the ATTACH test now calls the parser the executor runs, rather than a
byte-identical copy of its regex that no regression could reach.
admin is the login the data table reaches Postgres through, so revoking on
the database itself takes away what every role here connects with — and what
the role that would grant it back connects with. Refuse it server-side, and
stop the drawer from offering a row it cannot act on.
The ownership guard took the named-objects branch whatever the scope, and
returned. A revoke naming one table the caller owns, scoped to all tables,
therefore passed a check over that one table and planned a statement that
names the whole schema — the objects never reach the SQL there.
Owning the target is not owning what a change through it covers: a scope that
reads IN SCHEMA names every object in the schema, and handing a schema over
takes them all with it. Postgres would have skipped the ones the caller does
not own — these statements run as the data table's admin, so it will not.
Refuse, naming the object that is not theirs, and let a workspace admin
through as before.
A default-privilege rule speaks for the role that creates the objects, so a
non-admin now only writes them for the roles they may run as.
Also: the revoke button follows can_manage like the grant builder already did,
the copy path resolves a data table as admin for an admin (dumping as a
restricted role silently omits what it cannot read), and two doc comments now
sit on the function they describe.
A revoke's objects came from the request, argument types included, and those go
into the statement unquoted — so a schema owner could close a routine signature
and append SQL that ran as the data table's administrative login. The request
now only names an object: what reaches the statement is read back from the
catalog, and an object that resolves to nothing is refused.
The planners were behind `private`, which community builds carry, so the
permissions API answered on a CE binary. They take `enterprise` as well, with
a test that pins the refusal in every other edition.
Default privileges are read back scoped to one data table's own roles: two data
tables can share a database, and a new role of one was inheriting the other's
rules.
A role with a stored pg_rolename but no password resolved to the data table's
own connection, which owns everything — so a caller authorized as one role got
the admin one instead. Exports and git-synced settings redact that password, so
a restored config is exactly the shape that produced it. Refuse instead, and
name the fix.
The ACL endpoints took any workspace member: on a data table without roles
every member resolves to that same admin connection, so ownership and grants
there are the workspace admins' to change, as the roles themselves are.
Also: check a migration batch's roles before applying any of it, keep the role
picker for a single non-default role, drop the revoke button from a default
privilege on types (which no scope can express), and say what
get_datatable_resource_as_default_role actually resolves as.
Both planners now live in windmill-ee-private, reached through a dispatcher
that refuses in the open-source build, and an enterprise binary additionally
checks for an active license before planning anything. Reading a data table's
roles, its usable roles and a schema's owner and grants stays open, so an
instance that lapses can still see what it has; every mutation is refused.
The UI stops offering what the server would refuse: no Roles entry on a data
table, and no Permissions entry on a schema or table, without a license.
Functions live in pg_proc, so a schema changing hands left them behind and a
grant on ALL FUNCTIONS disappeared from the drawer on the next read, with no
way to revoke it. Read them alongside pg_class, and name them by their
identity arguments, which is what tells two of one name apart.
ALTER DEFAULT PRIVILEGES binds only the roles it names, so a role created
after a 'created later' grant produced tables no one else could read.
Replay the rules already in force for each new role.
The audit parameter of a data table config save carried the whole settings
blob, generated role passwords included. Redact it the way every other
export of that blob already is.
Both SDKs pasted the caller's role straight into the `-- role` annotation,
where a newline ends the comment and leaves the rest running as whatever the
first line named. Check the value against the role-name grammar the server
enforces.
Handing a privilege to a role means granting it, and a privilege held without
the grant option cannot be passed on — Postgres answers such a statement with
a warning and no effect, so the roles silently receive nothing. Databases
provisioned before those options were part of the instance grants still hold
them plain.
The repair now runs on the two paths that need it, saving permissions and
applying an ACL change, rather than only on opting in to migrations: a data
table can use roles without ever touching a migration.
A permissions drawer on a schema row: who owns it, and what each role may do
in it. Ownership moves the schema and everything already in it, and writes
the default privileges that keep the owner in reach of what the other roles
create later — an object belongs to whoever creates it, so that is the only
way to cover what does not exist yet.
Grants are built as statements — privileges, scope, role — and read back from
the catalog, per object and including default privileges, so what the page
shows is what the database has. Every change is confirmed against its own SQL
and runs in one transaction.
The editor takes a target rather than a schema: a table is the same call with
one more identifier, for when the table and role sections want it.
* fix: unify billable seat counting and prevent fork subscriptions
* fix: authorize candidate before reading its plan, scope seat breakdown
* chore: pin ee ref for the stripe checkout fork guard
* fix: grant the billable_member view and widen the paid-plan check
* refactor: keep the seat rule in rust instead of a view and function
* docs: correct the attach guard summary after widening the plan check
* revert: keep cloud out of the ci test feature set
* chore: update ee-repo-ref to 9ff97cd818e85940fec282c92161e98c1b8583e2
This commit updates the EE repository reference after PR #742 was merged in windmill-ee-private.
Previous ee-repo-ref: 0ec0b42565a41f271a45bf24a93467d110c36df3
New ee-repo-ref: 9ff97cd818e85940fec282c92161e98c1b8583e2
Automated by sync-ee-ref workflow.
---------
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
* fix: restrict filesystem workspace storage to debug builds
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7p2VbtYqaXHGaAskgwVk5
* chore: update ee-repo-ref to b58ad414b098d3d7787001a352bfbb13e43a335f
This commit updates the EE repository reference after PR #747 was merged in windmill-ee-private.
Previous ee-repo-ref: 1b4dada77a8fe2224579c643550c63b1ac2616de
New ee-repo-ref: b58ad414b098d3d7787001a352bfbb13e43a335f
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>