A fork's data table points either at the database of the workspace it was
forked from — where roles created here hold grants that workspace's own
config does not name, so its next save adopts or destroys them — or at a
clone whose whole database the fork can drop, taking the roles with it.
Neither is a place to build an access model, so the save and the preview
refuse it.
Turning them off stays allowed, or a fork carrying permissions from before
this rule could never be rid of them and the roles behind them never
dropped. The drawer says why, and the toggle is off only where it would be
refused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
* feat: guest JWT entry for embedded apps (jwt_guest_)
A second way in for a guest, alongside the signed-in guest session: a JWT the
embedding customer's backend mints and signs, verified per request against a
per-workspace key (a PEM public key or a JWKS URL), resolving to the same
seatless guest identity confined to the one app its app_path claim names.
Bearer prefix jwt_guest_, stateless (no token row). See PR #10954.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: surface guest JWT as the embed method in the app deploy drawer
The deploy drawer explained the secret-URL embed but not the guest JWT path, so
the primary way to embed an app for a customer's own authenticated users was
undiscoverable. For a guest-mode app with guests enabled, show how to mint a
`jwt_guest_` token and append `guest.<jwt>` to the app URL, with a copyable
iframe template pre-filled with this app's workspace_id and app_path, and a note
that new guest emails are refused past the instance's free allowance (the live
count is shown just above).
Also log a guest JWT allowance refusal at warn, not info: the caller gets a bare
401 (the reason must not leak to an unauthenticated caller), so the log is the
admin's signal that the instance hit its guest cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: correct the guest JWT minting instructions in the embed block
The block said "sign it with the workspace's guest JWT key", but that setting
holds the public verification key. Clarify the keypair relationship (configure
the public key or a JWKS URL in the workspace; sign with the matching private
key), name the accepted algorithms (RS/PS/ES; HS* refused), and keep the
required claims, so an embedder knows how to actually mint the token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: fall back to the instance JWT issuer for guest verification (off on cloud)
A workspace with no guest key of its own now verifies guest JWTs against the
instance issuer (JWT_EXT_JWKS_URL, already used by jwt_ext_), so an operator
running one issuer configures it once. Verification and the guest grant are CE;
granting a full login from that issuer stays EE (jwt_ext_, unchanged). Disabled
under CLOUD_HOSTED, where one instance issuer must not be trusted to mint guests
in every tenant's workspace — there the per-workspace key is the only source,
which also stays the override everywhere. The workspace settings note (hidden on
cloud) explains the fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: embed instructions cover both the workspace key and instance issuer
The embed block said to set the workspace's guest JWT key; now it says Windmill
verifies against the workspace key or, off cloud, the instance issuer
(JWT_EXT_JWKS_URL) when no workspace key is set. The instance clause is hidden
under isCloudHosted().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: show the guest JWT embed block only when Embed is toggled
It belongs with the iframe snippet, not the plain-URL view, so gate it on
embedMode alongside the guest-mode / guests-enabled checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: trust the instance issuer in the guest fallback; refresh stale docs
P1 (CI review): the fallback wrapped JWT_EXT_JWKS_URL as a workspace JwksUrl, so
it hit validate_guest_jwks_url and was refused for http/private issuers unless
ALLOW_PRIVATE_GUEST_JWKS_URLS was also set — a self-hosted internal issuer that
works for jwt_ext_ failed for guests, though the UI says setting the env var is
enough. fetch_jwks now fetches the instance issuer without the https/private
restriction (matching the jwt_ext_ loader; it stays operator-trusted), while a
workspace-admin URL is validated and pinned as before. All the size/key/URL
bounds still apply to both.
P2 (CI review): refresh the stale docs that said a missing workspace key always
refuses a guest JWT — the module, bearer, key-source, and EditGuestJwtKey field
docs now describe the workspace key with the off-cloud instance-issuer fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: fetch the trusted instance issuer like the jwt_ext_ loader
P1 (CI review): the instance-issuer fetch skipped SSRF validation but still
disabled redirects and default cert validation, so an instance issuer that works
for jwt_ext_ through a redirect or an operator-approved self-signed cert failed
the guest fallback. Fetch it with HTTP_CLIENT_PERMISSIVE (follows redirects,
honors ACCEPT_INVALID_CERTS) — the same behavior jwt_ext_ has — while a
workspace-admin URL stays validated, DNS-pinned and redirect-free. The body size
cap still bounds both.
P2 (CI review): the WorkspaceSettings field doc still said None/None means no JWT
guests; it now names the off-cloud instance-issuer fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: schema summary + OpenAPI cover the guest JWT columns and fallback
P2 (CI review): summarized_schema.txt was missing guest_activity.jwt_entry and
the two workspace_settings guest-JWT key columns (required by docs/validation.md
after a schema change). The edit_guest_jwt_key OpenAPI description now notes that
clearing the workspace key falls back to the instance issuer (JWT_EXT_JWKS_URL)
off cloud rather than necessarily stopping guest JWTs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: keep JWKS single-flight locks in a self-cleaning map, not a bounded cache
P1 (CI review): JWKS_FETCH_LOCKS was a 200-entry quick_cache. Past 200 cold URLs
it can evict a lock whose fetch is still in flight; the next request for that URL
then mints a fresh lock and starts a second fetch, so cycling configured
workspaces defeats single-flight and can storm the issuers. Replace it with a
plain map guarded by a JwksFetchLock RAII handle that removes each entry once its
last holder drops, so the map only ever holds the fetches in flight and never
evicts an in-flight lock. Add a unit test pinning the shared-lock and
self-cleaning invariants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: update ee-repo-ref to c2270eb5fe2d9f0968253e6b460c33186363f4e7
This commit updates the EE repository reference after PR #773 was merged in windmill-ee-private.
Previous ee-repo-ref: 5a1d9dee34159512c0823fddcd3d096490edbcce
New ee-repo-ref: c2270eb5fe2d9f0968253e6b460c33186363f4e7
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
A clone lands in a brand-new database where none of the roles exist, and
the fork's copy of the config is stripped of its permissions — so every
member of the fork, including one whose role reached two tables, resolved
to the copy's own owner connection and read the whole thing. The fork also
inherits every member of the parent, so it took one admin clicking "clone
schema and data".
Refused at the two clone endpoints and at the fork wiring itself, which is
the one a caller cannot go around. A plain database-to-database import is
untouched: it lands nowhere that strips permissions.
Sharing the original is the supported answer, and carrying the parent's
restrictions into a shared data table is the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
The fork-database drop cleared the permissions block and destroyed the
logins above the guards that refuse the drop, so a refusal — or a DROP
DATABASE that fails on an open session — left a live data table
unpermissioned, and every member of the workspace resolving to the
connection that owns it. The clear now happens only once the drop is
under way, and is put back if the drop does not happen.
The workspace-deletion snapshot takes the settings row first, so a
permissions save cannot add a login between the read and the row's
deletion.
The raw-app data drawer waits for the caller's usable roles before
mounting the content, like its sibling in the DB manager: mounting is
what fires the schema and metadata queries, and a first round sent
without a role runs — and caches — as whatever the server defaults to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
The save planned drops from the request's `deleted_datatables`, so a config that
drops one name and adds another — no rename declared, no deletion declared —
left the first one's logins for whatever data table is created under that name
next. What disappeared is the difference between the two configs.
Dropping a fork's data table databases dropped them before anything asked about
their logins, which are cluster-wide and outlive the database. The permissions
block is cleared first, both because a data table whose database is going has no
business claiming roles in it and because that is what the drop reads to know
they are nobody's.
The raw app's tree is asked for as the role the drawer is using, like its
sibling: it was always the default role's view, under a picker that changes the
role.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
Three ways it could stop being true, all of them ending with a login shared
between two data tables or outliving every record of itself:
A rename carried the roles verbatim, and their names are made from the data
table's name — so a data table created under the name that was left behind
generated those same names, adopted those logins and reset their passwords.
Renaming a permissioned data table is refused, the way pointing one at another
database already is.
Deleting a workspace removed the settings that name its logins without dropping
them. What to drop is resolved while that row is still there and run after the
commit, like a data table's own deletion — a workspace id is reusable, so what
survived was adoptable by the next workspace of that name.
Generating the initial migration takes a `pg_dump` with the data table's own
connection and returns every object in it, on nothing but `ApiAuthed` — a member
who may run as no role could read the whole schema through it. It answers to the
gate the rest of the migration management does.
The raw app's data drawer asks for its tree when it opens, like the manager's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
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.