Compare commits

...
Author SHA1 Message Date
Diego Imbert b9a303d4e6 fix(datatables): every change to a row restarts its streams, and an import names only principals that exist 2026-09-07 12:16:10 +02:00
Diego Imbert 6680c1454b fix(datatables): a governed database is not cloned, an opt-in restarts its triggers, and a cleanup respects an adopted row 2026-09-07 11:58:48 +02:00
Diego Imbert 47adfc0844 fix(datatables): the database name is taken exactly, the export is admin-only, and a dropped clone forgets its row 2026-09-07 11:37:04 +02:00
Diego Imbert 2457db4da4 fix(datatables): an import carries no login names, a login-less role reaches nothing, tenants are the owner's 2026-09-07 11:22:03 +02:00
Diego Imbert a431bf27e4 fix(datatables): the ACL editor and the import answer to the owning workspace, not the calling one 2026-09-07 11:06:42 +02:00
Diego Imbert f897805978 fix(datatables): a deletion takes only the permissions it owns, and the row outlives its owner closed 2026-09-07 09:49:17 +02:00
Diego Imbert e1f387914e refactor(datatables): role permissions belong to the database, keyed by it and owned by the workspace that turned them on 2026-09-07 07:49:29 +02:00
Diego Imbert f9464c2b8e fix(datatables): a second door is refused where it resolves, by the database itself rather than the login 2026-09-06 22:00:17 +02:00
Diego Imbert 8842dea7f7 fix(datatables): a resource edit cannot point an entry at a governed database 2026-09-06 15:27:11 +02:00
Diego Imbert e6c91aebeb fix(datatables): the same-database scan reads resource rows, the settings form refuses a second door, and a fork keeps no orphan migrations 2026-09-06 13:37:19 +02:00
Diego Imbert c08cbc98f7 fix(datatables): any data table reaching the same database counts, a removed role cannot strand a migration, and the drawer's modals are keyed 2026-09-06 09:36:09 +02:00
Diego Imbert e913381888 fix(datatables): a save names only what exists, and a same-path resource counts when it reaches the same database 2026-09-06 08:12:16 +02:00
Diego Imbert 0c54cdb752 fix(datatables): the clone stamp is server-owned, and a kept original never carries one 2026-09-06 02:16:34 +02:00
Diego Imbert 01567c2d30 fix(datatables): a resource-backed clone is not a copy, and the two helpers state their contract 2026-09-06 01:28:56 +02:00
Diego Imbert 73628dea4b fix(datatables): an archived fork still reaches the database, and raw-app AI tools run as the app's role 2026-09-06 00:56:55 +02:00
Diego Imbert 1aac111b80 fix(datatables): only the opt-in is gated, and a rename leaves no permissioned copy behind 2026-09-06 00:02:05 +02:00
Diego Imbert ddae0ec609 fix(datatables): permissions are enabled where the workspace alone reaches the database 2026-09-05 21:26:17 +02:00
Diego Imbert d4c0c83e0b Merge remote-tracking branch 'origin/main' into datatable-perms-3 2026-09-05 21:07:24 +02:00
Diego ImbertandClaude Opus 5 af0477c16f fix(datatables): a workspace-id change leaves one owner for each login
The rename copies the whole config, so the archived id and the live one both
named the same generated logins — and deleting the archive planned drops for
logins the renamed workspace was still using, destroying their grants.

The archived copy stops naming them: it is kept for reference, and a
reference needs no credentials. The renamed workspace keeps working, since
resolution uses the stored `pg_rolename` rather than the generated one, and
its next permissions save finds the stored name no longer matches what this
workspace id generates and renames the login to match — under the ownership
proof that rename already carries.

Also: a pending row action now has to name the same data table the manager
is open on, not merely fail to differ from it. `undefined` — a plain
postgres resource, or a DuckLake — is somewhere else too, and a matching
schema and table name there would have run it. Both drawers drop what is
pending when they close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-05 21:06:36 +02:00
Diego ImbertandClaude Opus 5 6eb63fa58b fix(datatables): the roles drawer's Save button is sized like every other
It was the one `unifiedSize="xs"` (h-5) among the drawer action buttons,
where the convention is `md` (h-8), so it read as a different, lesser
control than the Save it sits beside everywhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-05 21:06:35 +02:00
Diego ImbertandClaude Opus 5 63ef7663fc fix(datatables): permissions cannot be enabled from a fork
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
2026-09-05 21:06:35 +02:00
Diego ImbertandClaude Opus 5 c6c26fbd92 fix(datatables): a permissioned data table cannot be cloned into a fork
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
2026-09-04 22:06:04 +02:00
Diego ImbertandClaude Opus 5 1e6d28def2 fix(datatables): a data table that keeps its database keeps its roles
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
2026-09-04 20:08:14 +02:00
Diego ImbertandClaude Opus 5 95c3059266 fix(datatables): what disappeared is read from the config, and its logins go first
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
2026-09-04 19:08:00 +02:00
Diego ImbertandClaude Opus 5 ec19b0fe21 fix(datatables): a generated login belongs to one data table in one workspace
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
2026-09-04 18:50:00 +02:00
Diego ImbertandClaude Opus 5 443d8a5316 fix(datatables): the manager reaches every database only when it is open
This PR swapped the drawer's data table list for one that reports each table's
schemas and privileges, which connects to every configured database in turn —
and the drawer is mounted on every logged-in page, so that ran on page loads
with the manager closed, up to a connection timeout per unreachable database.
It is asked for when the drawer is open.

The raw app's data drawer keeps a role across sessions while both entry points
replace the data table under it, so browsing one table as `analyst` and then
opening another queried that one as its own `analyst`, or as a role it does not
have. Both clear it, like the in-drawer switch already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 18:08:23 +02:00
Diego ImbertandClaude Opus 5 3de44a8f8d test(datatables): pin which resolutions prove the database, not the comparison
The test added with the last commit called the comparison directly, and that
never took a role — moving the call back inside the branch that swaps credentials
would leave it passing. The rule is a function now, and asserting it fails when
the rule is reverted: an identified caller of a permissioned data table proves it
whatever role it lands on, the machinery that repairs one does not, an
unpermissioned one has nothing to prove.

The exemption's comment names what `Unchecked` actually covers. Two of its
callers are not repair: the admin diagnostic, and the postgres trigger's
replication connection — which reaches the data table's own connection the way
every caller did before roles existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 17:51:46 +02:00
Diego ImbertandClaude Opus 5 9580e9985a fix(datatables): admin follows the same record as every other role
`admin` has no login to swap in — it is the data table's own connection — so the
check that the data table still points where its roles live sat inside the branch
that swaps credentials and never ran for it. A caller granted `admin` was handed
whatever the resource pointed at, which is the case the record exists for: the
guards on the resource can be gone around through a `$var:` or a concurrent
enable. It runs for every permissioned data table now, whatever role the caller
lands on, and only for callers with an identity — the machinery that repairs a
moved data table resolves as admin and has to keep working, which is what
re-saving its permissions does.

The disabled schema toggle's tooltip names the specific refusal first, like the
hint beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 17:40:30 +02:00
Diego ImbertandClaude Opus 5 1b82a7c236 fix(datatables): the specific refusal is the one worth saying
With no usable role the access question goes out role-less, which the server
answers as the data table's default — the role that was refused — so the entry
comes back with an error and both refusals are true at once. The one that names
the cause is tested first.

`_format_py_params` is now what its comment claimed: positional-only parameters
keep their `/`, their defaults line up with the positional ones, and the second
renderer is gone rather than kept one clause behind. Every generated file comes
out byte-identical, since nothing in the SDK uses `/` yet — which is exactly how
the missing `*` survived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 17:20:23 +02:00
Diego ImbertandClaude Opus 5 7cd6e081a7 refactor(prompts): one renderer for Python signatures
`extract_py_functions` inlined a third copy, one clause behind the two functions
that already do this — it lost the bare `*` until last commit, and still dropped
positional-only arguments and annotated varargs. It calls the shared one; the
generated files come out byte-identical.

The picker says which of the three refusals it hit, including the database it
could not reach, and an app that keeps no data table no longer creates a schema
in one: the app would never refer to it, and where the database is unreachable
the failure is one the user cannot act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 17:09:35 +02:00
Diego ImbertandClaude Opus 5 8a40ff46fd fix(prompts): a keyword-only argument is advertised as one everywhere
The general Python extractor dropped the bare `*` the same way the datatable one
did, so every keyword-only signature it emits — `datatable`, `task` and the rest
— told an agent to pass those arguments positionally, which is a TypeError.
Regenerated: the script prompt, the Python skill and the CLI guidance carry the
separator now.

The fork dialog says both outcomes where the permission check did not answer:
what the backend does with the data table is decided by the config, so a label
promising the original is kept is a promise it cannot make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:59:07 +02:00
Diego ImbertandClaude Opus 5 b1474820b5 fix(datatables): a database that could not be reached is not a role that refused
`can_create_schema: false` on an entry the backend kept with an error says
nothing about the role, but the modal read it as one: it left `New`, could not be
put back, and the tooltip blamed the role for a database it never reached. The
access answer carries its failure, the flip waits for an answer, the tooltip says
what actually happened, and an app that would name that data table waits with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:52:01 +02:00
Diego ImbertandClaude Opus 5 93590da9d8 fix(datatables): a lookup that failed is not an answer
An empty role list meant "nothing to pick" whether the endpoint said so or never
answered, so a failed lookup on a permissioned data table let an app be created
naming it with no role — the case the check exists for. The failure is carried,
and an app that would name the data table waits for an answer.

The fork dialog said "Keep original" on the same failure, for a data table the
backend drops from the fork when its permissions are on: it says the check did
not answer instead of asserting the outcome.

The generated Python signature keeps the bare `*`. `datatable(name, *, role)` is
keyword-only, and an agent following the advertised positional form gets a
TypeError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:38:11 +02:00
Diego ImbertandClaude Opus 5 7c68818da1 fix(datatables): a role picked at creation is the role the app keeps
Leaving it out when it happened to equal the data table's default meant "whatever
the default is then", so an app created as `analyst` became a `reader` app the day
an admin changed that default. The pick is written down.

The hint says what is true of the choice in front of it and no more: what the
assistant writes when an app names no data table is a property of the prompt, not
of this data table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:32:42 +02:00
Diego ImbertandClaude Opus 5 a253ba3d11 fix(datatables): the hint states the situation rather than a way out that is not one
Turning table creation off lets the app be created, but it does not stop the
assistant writing queries against the data table: with none named, what it writes
falls back to the workspace's default — the same one the caller may run as no
role on. The hint says what will happen instead of recommending an escape that
leads back to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:21:34 +02:00
Diego ImbertandClaude Opus 5 6608180e3e fix(datatables): the default-database popover waits for the answer too
Its Schema select read the access resource without the stamp its sibling reads,
so switching the database one row above left the previous data table's schemas
listed and clickable — a schema name the new data table may not have, written
into the app's policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:12:32 +02:00
Diego ImbertandClaude Opus 5 bdcd42ac0a fix(datatables): only the app that would name the data table is refused
A caller who may run as none of a permissioned data table's roles could not
start anything: the refusal ignored whether the app was going to name it at all.
With table creation off nothing saves the data table, and an app that never
touches it is not theirs to be stopped over — so the buttons look at that, and
the hint says what the way out is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 16:02:09 +02:00
Diego ImbertandClaude Opus 5 4638f35951 fix(datatables): a workspace with no data tables has nothing to wait for
The gate that keeps the modal from deciding before an answer arrives never
settles where there is no question: with no data tables, nothing is selected, so
both Start buttons stayed disabled under an alert saying an app can still be
created. Waiting is for a data table that was asked about.

A data table this caller may run as no role on is refused instead: leaving the
role out saves its configured default, which is the one they cannot use, so the
app would be created with queries that are refused. The picker says so where the
data table is chosen.

The six rounds of fixes around the loading state left three comment blocks two
paragraphs deep, one of them documenting the item below it. The rule they all
restate is written once, where a reader would break it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 15:53:30 +02:00
Diego ImbertandClaude Opus 5 1e2052d74a fix(datatables): undefined on both sides is not an answer
Before auto-select lands there is no data table to have asked about, and the
initial value stamps none either, so the gates added to keep the schema mode from
flipping against a non-answer compared `undefined` with `undefined` and let it
through at mount — the modal opened on `None` rather than the `New` it declares,
and an app started without touching the toggle got no schema. Settled now means a
data table is selected, the role list answers for it, and the access answer was
asked as the role the app will run as.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 15:31:19 +02:00
Diego ImbertandClaude Opus 5 3d2fa49358 fix(datatables): the role an app can use is the one it saves
Hiding the picker where there is one usable role meant saving none, and no role
means the data table's configured default — which is not filtered by what the
caller may use. A member whose only role is `admin` on a data table defaulting to
`analyst` got an app whose queries are refused. The role is saved whenever it is
not what the data table would resolve to anyway, and the schemas are asked for as
that role whether or not the picker is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 15:21:10 +02:00
Diego ImbertandClaude Opus 5 2d8a99d2a3 fix(datatables): an answer about another role settles nothing
What a role may create in is the question the access resource asks, so an answer
computed as a different one is not an answer to it — and there is always one of
those: the role getter is empty until the role list settles, so every switch
fetches once with no role, which the server reads as the data table's configured
default. That answer stamped the data table alone, passed the check, and its
one-way flip took the modal off `New` for a role that can create schemas.

Both halves of the key are stamped and compared now, and Start waits for that
answer too: a new schema name checked against a list from another question is
not checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 14:49:51 +02:00
Diego ImbertandClaude Opus 5 8868c5ff72 fix(datatables): nothing is decided before the answer for this data table arrives
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
2026-09-04 14:41:11 +02:00
Diego ImbertandClaude Opus 5 1e3e08566d fix(datatables): a row action names the data table it was asked for
A pending action kept only what to do, so a switch that landed anywhere else —
the target failed to load, or the user moved on — ran it against whatever the
manager was connected to by then. Dropping a schema is not a thing to do by
approximation: it carries its data table and is discarded when that is not where
we are.

The new-app picker reads the role list only once it answers for the data table
selected. During a switch it offers nothing rather than the previous table's
roles, and an app created in that window gets the new table's default instead of
a name carried over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 14:17:35 +02:00
Diego ImbertandClaude Opus 5 7f09781926 fix(datatables): the role list answers for the data table it was fetched for
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
2026-09-04 13:41:28 +02:00
Diego ImbertandClaude Opus 5 e6ca0de9c3 fix(datatables): a fork does not carry a copy of who may run as what
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
2026-09-04 13:14:51 +02:00
Diego ImbertandClaude Opus 5 a274fd85bc fix(datatables): a role belongs to the data table it was chosen on
An app's default data table can be changed after it is created, and both editors
that change it kept the role picked for the previous one — so its queries named
a role that data table has never heard of. The rule is one function now, used by
both, and the selector lists schemas as the role rather than as whatever the
data table resolves to by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 12:36:53 +02:00
Diego ImbertandClaude Opus 5 e4ce3a466a fix(datatables): order the offboarding that takes the locks, not the preview that reads
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
2026-09-04 12:11:25 +02:00
Diego ImbertandClaude Opus 5 2d09d33997 fix(datatables): the three transactions that still locked before the settings row
Offboarding reassigns a departing user's scripts, flows, apps and resources —
rows a rename writes while holding the settings row — so it takes that row
first, and offboarding from several workspaces walks them in `workspace_id`
order. Removing a member locked the `usr` row before the removal reached the
settings row, and a rename renamed the `password` row before its first
workspace: both now come after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5arH3G2Sa1Qqm32veJQ1n
2026-09-04 11:47:52 +02:00
Diego ImbertandClaude Opus 5 8bb2b0d777 fix(datatables): one lock, taken first, instead of an order per handler
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
2026-09-04 11:28:44 +02:00
Diego ImbertandClaude Opus 5 b58a036dd8 fix(datatables): nothing irreversible happens before the save that asks for it commits
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
2026-09-04 10:48:58 +02:00
Diego ImbertandClaude Opus 5 43d2c29988 fix(datatables): a rename reaches only the workspace it is renaming in
`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
2026-09-04 10:09:48 +02:00
Diego Imbert 42271dcf7f Merge remote-tracking branch 'origin/main' into datatable-perms-3
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-04 09:32:27 +02:00
Diego ImbertandClaude Opus 5 f3e3281336 fix(datatables): a deferred role drop stops at the data table that holds those roles now
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
2026-09-04 08:47:14 +02:00
Diego ImbertandClaude Opus 5 1f872c5edb fix(datatables): a role only works where it was created, and only under its own name
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
2026-09-03 23:59:04 +02:00
Diego ImbertandClaude Opus 5 23c1fc470f fix(datatables): one lock order for every path that frees a username, one badge
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
2026-09-03 21:37:11 +02:00
Diego Imbert dfc06bf821 test(datatables): pin that freeing a principal takes its tenant with it
The cleanup is one helper called from five routes, and the bug each time was a
route that did not call it. This drives four of them — a group deletion, a
folder deletion, a member leaving, a superadmin deleting the account — against
a real API server and asserts the role stops naming them, while the `*`
wildcard, which no deletion can free, is left alone.
2026-09-03 19:50:59 +02:00
Diego Imbert 2c55c3918c fix(datatables): every route that frees a username takes its tenants with it
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.
2026-09-03 19:43:39 +02:00
Diego Imbert 625753c2d4 fix(datatables): one lock for everything that reads the config and writes it back
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.
2026-09-03 12:17:26 +02:00
Diego Imbert 0fc9ba39a7 fix(datatables): a tenant and a resource path follow the principal they name
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.
2026-09-02 10:27:28 +02:00
Diego Imbert 83acf4ba56 fix(datatables): follow the resource through variables, and a tenant through a rename
A linked variable owns the resource at its path: deleting one — singly or in
bulk — deletes it, and renaming one moves it, so all three answer to the rule
the resource endpoints do.

And a data table role's tenants are stored as u/<username>, which the executor
compares against the caller's name. A username rename that skipped them took
the role away from the user it followed and left it waiting for whoever took
the old name next.

Also: the ACL endpoints stopped saying 'admins only' — a non-admin may act on
what their role owns, which is the contract the handlers implement.
2026-09-02 00:39:12 +02:00
Diego Imbert 65401f39fe fix(datatables): a resource cannot be taken out from under a permissioned data table
Comparing identities only answered for an edit that kept the resource. Clearing
its value, deleting it, renaming it away or bulk-deleting it all left the config
naming a database its roles were never created in — and the next resource at
that path would answer for grants it never had. Every one of those refuses now
while permissions are enabled, and the same disable-first escape stays.
2026-09-02 00:18:24 +02:00
Diego Imbert 2cde025d8e fix(datatables): guard the resource upsert too, and cache its query
create_resource with update_if_exists is an edit when the row is already
there, so it answered to none of the rule the other two write paths do. And
the lookup the guard makes had no offline entry, which is what CI compiles
against.
2026-09-02 00:00:07 +02:00
Diego Imbert c289a40e5b fix(datatables): keep role passwords out of the settings every member reads
`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.
2026-09-01 23:52:05 +02:00
Diego Imbert 68e7f72560 fix(datatables): the database a permissioned data table points at cannot move
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.
2026-09-01 23:37:05 +02:00
Diego Imbert 98adfd247b chore(ee): point at the rename-message fix 2026-09-01 23:23:32 +02:00
Diego Imbert 88d6e11ff0 fix(datatables): a permissioned data table cannot be pointed at another database
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.
2026-09-01 23:19:42 +02:00
Diego Imbert 6e79d9636f fix(datatables): keep the database's own grants to admin out of 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.
2026-09-01 23:15:43 +02:00
Diego Imbert be186e6fdd fix(datatables): the objects a revoke names do not answer for its scope
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.
2026-09-01 23:10:53 +02:00
Diego Imbert d6f36d3135 test(datatables): pin the object classes a schema-wide change reaches 2026-09-01 23:05:00 +02:00
Diego Imbert 9a63c6884d fix(datatables): count materialized views among the tables a scope reaches 2026-09-01 23:02:54 +02:00
Diego Imbert 6768e2a9e9 fix(datatables): check what a change reaches, not only what it names
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.
2026-09-01 23:00:51 +02:00
Diego Imbert 8a949471d0 fix(datatables): say (EE) on the entries a license disables
A greyed-out 'Permissions' only tells the reader that they cannot click it.
Suffix it the way the sidebar already marks its enterprise entries, so the
reason is on the row itself rather than only in the tooltip.
2026-09-01 18:05:07 +02:00
Diego Imbert fa4c06658f fix(datatables): resolve ACL objects against the catalog, and gate on the enterprise edition
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.
2026-09-01 11:58:58 +02:00
Diego Imbert 45d5eeda0e fix(datatables): close the fail-open in role resolution, and gate the ACL endpoints
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.
2026-09-01 04:48:44 +02:00
Diego Imbert 28b31a437e feat(datatables): gate role and ACL changes on the enterprise edition
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.
2026-09-01 04:32:52 +02:00
Diego Imbert 5f59b199b1 test(datatables): pin the audit parameter's redaction 2026-09-01 04:14:29 +02:00
Diego Imbert 159a550df8 fix(datatables): carry the role into a late down migration, and unblock create-schema
Enabling Down after picking a role left the rollback with no annotation, so
it ran as the default role. Creating a schema on a data table that has none
waited for a schema to appear, which it never would.

Also moves the new ACL buttons off the deprecated Button props.
2026-09-01 04:09:25 +02:00
Diego Imbert 2bc39481da fix(datatables): cover routines in ownership, grants and default privileges
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.
2026-09-01 04:09:19 +02:00
Diego Imbert 7619a7a51e fix(datatables): keep role passwords out of audit, and role names out of SQL
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.
2026-09-01 04:09:12 +02:00
Diego Imbert 1f6aec5470 fix(raw apps): show the schemas the picked role can reach 2026-09-01 03:39:06 +02:00
Diego Imbert b2b6ee773b fix(raw apps): pass the data table role the way the client takes it 2026-09-01 03:32:42 +02:00
Diego Imbert 70c610cc78 fix(raw apps): read the data table and its role as one phrase 2026-09-01 03:29:27 +02:00
Diego Imbert 9e6c5cb524 feat(raw apps): pick the data table role when setting up an app 2026-09-01 03:24:58 +02:00
Diego Imbert a4e6eb5e4d fix(datatables): never hide access management from a workspace admin 2026-09-01 03:11:23 +02:00
Diego Imbert e3f0a22810 feat(datatables): manage access on what you own, not only as an admin 2026-08-31 23:39:49 +02:00
Diego Imbert 67a4e2c5ef fix(ui): scroll a wide code preview instead of widening its modal 2026-08-31 23:39:49 +02:00
Diego Imbert 22f99f99f1 fix(datatables): grant created roles to admin with the SET option 2026-08-31 23:39:49 +02:00
Diego Imbert 09cab14813 Merge remote-tracking branch 'origin/main' into datatable-perms-3 2026-08-31 22:46:55 +02:00
Diego Imbert fea9f1836c nit 2026-08-31 22:12:44 +02:00
Diego Imbert 709cf8aecb Merge remote-tracking branch 'origin/main' into datatable-perms-3
# Conflicts:
#	backend/ee-repo-ref.txt
2026-08-31 22:01:47 +02:00
Diego Imbert eb95a95f48 feat(datatables): a permissions drawer on a table 2026-08-31 06:52:03 +02:00
Diego Imbert a8a8812839 feat(datatables): group grants that repeat over objects into one row 2026-08-31 06:45:37 +02:00
Diego Imbert 7b94a0fb4d feat(datatables): rename a schema from its row menu 2026-08-31 06:20:13 +02:00
Diego Imbert e989a4d9ae fix(datatables): report create privileges for the role the manager is on 2026-08-31 06:01:13 +02:00
Diego Imbert 6e76edb279 fix(datatables): tell the caller when roles have been saved 2026-08-31 06:01:13 +02:00
Diego Imbert 19035031b7 fix(datatables): stop offering to grant CONNECT on a database
Every role a data table creates is granted it at creation, so the statement
could only restate what is already true. The backend still accepts it, so a
CONNECT grant already in the catalog stays revocable.
2026-08-31 06:00:11 +02:00
Diego Imbert baaf7632a3 fix(datatables): hide schemas the connection cannot enter from the tree
The right join is what keeps an empty schema in the list, and it reads
pg_namespace, which every role can read in full — so a schema the role has no
privilege on came back too, as an empty one, since information_schema does
filter. An empty schema it can enter still shows.
2026-08-31 06:00:11 +02:00
Diego Imbert fc3732fb7f fix(datatables): repair an instance database's grant options where they are used
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.
2026-08-31 05:55:50 +02:00
Diego Imbert 38023de582 feat(datatables): only offer create where the role may create 2026-08-31 05:52:42 +02:00
Diego Imbert 422cc98130 nit 2026-08-31 05:36:23 +02:00
Diego Imbert e1556004f6 feat(datatables): grant database privileges from the roles drawer 2026-08-31 05:19:51 +02:00
Diego Imbert 9ebca89e28 feat(datatables): badge the data table the manager is connected to
The tree lists every data table; only one of them answers the queries.
2026-08-31 05:17:23 +02:00
Diego Imbert 1eb314e8cb feat(datatables): fold removing a data table into its row menu
The menu takes the place the close button held at the end of the row, and
migrations and permissions grey out while the row has unsaved edits — they
act on the saved data table, which an unsaved one is not.
2026-08-31 05:15:16 +02:00
Diego Imbert 395eac68c7 fix(datatables): give custom_instance_user the grant option on instance databases 2026-08-31 05:07:01 +02:00
Diego Imbert ce89023cef fix(datatables): settle the role from the data table being switched to 2026-08-31 04:58:39 +02:00
Diego Imbert 924389a0ac fix(datatables): test the connection as the data table's own admin role 2026-08-31 04:39:56 +02:00
Diego Imbert 1244d8015d refactor(datatables): move migrations and permissions into the row menu 2026-08-31 04:39:56 +02:00
Diego Imbert 8429d909d4 fix(datatables): accept postgres 17's MAINTAIN table privilege 2026-08-31 04:33:22 +02:00
Diego Imbert bac89b0790 fix(datatables): allow deleting a migration that has already been applied 2026-08-31 04:33:22 +02:00
Diego Imbert 7effde8bbe fix(datatables): hide schemas the connection's role cannot reach 2026-08-31 04:33:22 +02:00
Diego Imbert 163ff3aad6 feat(datatables): drop a schema from its row menu 2026-08-31 04:15:21 +02:00
Diego Imbert 126204a11e fix(datatables): resolve pg_database_owner to the database's real owner 2026-08-31 04:15:21 +02:00
Diego Imbert 6774ddc81b nit 2026-08-31 04:03:42 +02:00
Diego Imbert 803e89319e feat(datatables): own and grant on a data table's schemas
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.
2026-08-31 04:03:42 +02:00
Diego Imbert c4e350e85c feat(datatables): lead tree rows with their chevron, keep the row menu visible
The favourite star follows the table name, showing on hover unless the table
is one — where the star is saying something, it stays.
2026-08-31 04:03:42 +02:00
Diego Imbert 7e00022f24 fix(datatables): put the row menu before the chevron, not over the row icon 2026-08-31 04:03:42 +02:00
Diego Imbert 7570349f2e feat(datatables): switch a data table's role from its row badge
The badge is now a button with a chevron, opening the select dropdown over
the roles the caller may use — the dropdown alone, not Select's own input.
Picking one reconnects the manager as that role, switching data table first
when the badge belongs to another one.
2026-08-31 04:03:42 +02:00
Diego Imbert bebd8bef8b feat(datatables): row menus everywhere, in place of the row icon
The menu now replaces a row's own icon on hover rather than its chevron, and
a table row keeps its icon with the favourite star moved to the far right.

Table menus are no longer limited to the data table the manager is connected
to: alter and delete route through the same switch the create rows use, so
they run against the connection of the data table the row belongs to.
2026-08-31 04:03:42 +02:00
Diego Imbert ae162b6d3e Merge branch 'main' into datatable-perms-3 2026-08-26 10:24:13 +02:00
Diego Imbert 7e5ac78447 feat(datatables): badge each data table row with the role it is reached through
Serve the caller's usable roles and the default one alongside the tree, so
the badge costs no extra round trip per data table. Shown only where naming
the role says something: a data table without permissions, or one whose only
usable role is the implicit admin, gets none.

The left-pane filter also matches schema names now.
2026-08-25 21:00:26 +02:00
Diego Imbert 983b514a5d enableFlyTransition 2026-08-25 20:47:46 +02:00
Diego Imbert ba69fee329 fix(datatables): show a row's menu on hover only, never because it is current 2026-08-25 20:42:06 +02:00
Diego Imbert 77965f1458 feat(datatables): create tables and schemas from any data table in the tree
The New table / New schema rows now show under every data table, not only
the one the manager is pointed at. Creating outside it switches to that data
table first, and the request rides through the parent because the switch
re-mounts the manager.

Tree rows also read uniformly now — the background alone marks the current
one — with the create rows set apart in secondary.
2026-08-25 19:47:50 +02:00
Diego Imbert 48294239e5 fix(datatables): key a table's favourite star on its own data table 2026-08-25 19:31:39 +02:00
Diego Imbert 3f92ab39ad fix(datatables): tick a picked table only from its checkbox 2026-08-25 19:28:48 +02:00
Diego Imbert 18d6302337 fix(datatables): put the picker checkboxes on the left, keep the chevrons right
Picking a table now also makes it the current one, previewed on the right —
across data tables too, which re-mounts the manager, so the picked
schema/table travel through the input the drawer keeps.
2026-08-25 18:42:59 +02:00
Diego Imbert 44314d6e7a fix(datatables): keep the new table/schema rows in the table picker 2026-08-25 18:34:52 +02:00
Diego Imbert b15bac1916 refactor(datatables): make multi-select a mode of the DB manager tree
The table picker for raw apps had its own checkbox tree next to the
manager's navigator. Now that the navigator is itself a data table ->
schema -> table tree, the picker is the same tree with a checkbox in
place of the chevron and row menu, so a pick can span data tables.

Also aligns the indent step between tree levels at 16px.
2026-08-25 18:27:32 +02:00
Diego Imbert bdb59ea399 fix(datatables): align the tree's indent steps 2026-08-25 18:10:13 +02:00
Diego Imbert e94d1b63ed fix(datatables): inset tree rows with padding and unstick the row menu after a click 2026-08-25 17:47:46 +02:00
Diego Imbert 871f2054a5 nits 2026-08-25 17:35:48 +02:00
Diego Imbert 0cc25e2904 fix(datatables): stack the tree row menu over its chevron and outline the favorite star 2026-08-25 17:31:26 +02:00
Diego Imbert 61f5e1b1f3 feat(datatables): move database manager actions into the tree row menus 2026-08-25 15:05:43 +02:00
Diego Imbert b3bee6ed1c nit 2026-08-25 14:52:07 +02:00
Diego Imbert 2eda17d6a2 nit 2026-08-25 14:49:54 +02:00
Diego Imbert 92eec3f696 fix(datatables): make the database manager tree foldable and compact 2026-08-25 14:44:52 +02:00
Diego Imbert 4a20ac3ffd feat(datatables): navigate the database manager with a data table tree 2026-08-25 14:21:17 +02:00
Diego Imbert 665cadbca6 feat(datatables): take the sdk role as an options argument 2026-08-25 14:08:37 +02:00
Diego Imbert 6228e266a1 feat(datatables): run migrations as the role their SQL declares 2026-08-24 14:44:29 +02:00
Diego Imbert 326fb5e5cb fix(datatables): hide the database manager role picker when there is one role 2026-08-24 09:25:42 +02:00
Diego Imbert 004b73ff37 nit wording 2026-08-24 08:55:45 +02:00
Diego Imbert 4e53d1ac45 chore(datatables): regenerate cli/system-prompt docs for the datatable role param 2026-08-24 08:46:17 +02:00
Diego Imbert 5974f5491d Merge remote-tracking branch 'origin/main' into datatable-perms-3
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/parsers/windmill-parser/src/asset_parser.rs
#	backend/windmill-common/src/workspaces.rs
2026-08-24 08:39:01 +02:00
Diego Imbert 98cf770d73 refactor(datatables): shorten generated pg role names to wm_<role>_<hash> 2026-08-04 22:32:56 +02:00
Diego Imbert 77ebdf08b2 refactor(datatables): rename the built-in role from root to admin 2026-08-04 22:29:21 +02:00
Diego Imbert 62ac83431d fix(datatables): keep new roles out of the public schema 2026-08-04 22:24:34 +02:00
Diego Imbert 4717fff51f fix(datatables): portal the permissions confirmation modal above the drawers 2026-08-04 22:09:41 +02:00
Diego Imbert dbf8af2116 fix(datatables): grant created roles to root so their objects can be reclaimed 2026-08-04 21:43:57 +02:00
Diego Imbert 58df573b15 feat(datatables): pick the role the database manager connects as 2026-08-04 21:07:27 +02:00
Diego Imbert 399438bca1 feat(datatables): let a data table choose which role a script gets by default 2026-08-04 20:22:13 +02:00
Diego Imbert afdf83d2cb fix(datatables): keep permissions on shared forked data tables and order role renames 2026-08-04 20:07:57 +02:00
Diego Imbert 7285e50b76 fix(datatables): redact role passwords from settings exports and harden role naming 2026-08-04 19:32:26 +02:00
Diego Imbert 23cd7737b6 feat(datatables): opt-in role-based permissions 2026-08-04 18:59:03 +02:00
117 changed files with 10449 additions and 976 deletions
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path, postgres_resource_path\n FROM postgres_trigger WHERE postgres_resource_path LIKE 'datatable://%'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "postgres_resource_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "1100d1ab19ed3bf73ce29af00d7a06605e7b697324813a1dce1c2fd4c72bc713"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)\n WHERE workspace_id = $1\n AND datatable->'datatables' ? $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "161cc8750a9c6f22b28bc0cc565c621cd3e6ee378587620deb0ea2f69f1fcbdb"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM usr WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "1ad8677694aca94ee0e6da287d7cc028dcf673583a0e3e4fedd0e5d6766c5860"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = datatable #- ARRAY['datatables', $2, 'permissions']\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2daf3786f0b7195a3acfd80e01f2dba171ae5850f22503e846b7355a4f250611"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id,\n jsonb_each(ws.datatable->'datatables') dt\n WHERE ws.workspace_id = ANY($1)\n AND dt.value->'database' = $2\n AND dt.value->'forked_from' IS NULL\n ORDER BY ws.workspace_id, dt.key",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "deleted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"TextArray",
"Jsonb"
]
},
"nullable": [
false,
null,
false
]
},
"hash": "34a6b159360d652fac09662cedab4a490be9db7e331874cbfa7b1b93e50af586"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE database_key = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "database_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "owner_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "permissions",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "3abcd060b4e1865f17e73ae7e17b0ba654f21458fb4d2d0a55e5c3eab29bb279"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock(hashtext('datatable_database_permissions:' || $1))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "3cb0fdbb787328c61696a3f4657041bf4019929e5911cf4f144f24dd6916b106"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE datatable_database_permissions SET owner_workspace_id = $1 WHERE owner_workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "3f11db5b3f8af739cb287f45071f8e315e3e078b3d130d0db48277306b189ea9"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "43ce5ba6277a6e7284b2d5e9fe29f5489d8ceccb7c971226108ed600278cfbea"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', (\n SELECT COALESCE(jsonb_object_agg(\n key,\n CASE WHEN key = ANY($2) THEN value ELSE value - 'forked_from' END\n ), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "47fc4d3ffd3ace0beb29cd3c4c3125c1d0a76781c8340e65a72804f469d0f6c8"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)\n WHERE workspace_id = $1 AND datatable->'datatables' ? $2\n RETURNING workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "49ce3f131435f763dad8312c1efc569f005682897d2d8525702ea77c1f9ea99d"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name AS \"name!\" FROM folder WHERE workspace_id = $1 AND name = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "4a28a373afac48cce81f4607e437be329096b574108bc0ec41290d993318991d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET username = $1 WHERE email = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "58002bfd04efea3d5f955d0bc5073a916b00fd73600943b98f69639fac2071be"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "58e5cfe9eb87bda9f7de87c403861b6e7b9d35a41594681e2a92a87359e6a018"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username AS \"username!\" FROM usr WHERE workspace_id = $1 AND username = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "5d8a2a1e4ee7f63049f910ed56a71a8f2db475177732931d7973f2053ad79442"
}
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id AS \"workspace_id!\", datatable AS \"datatable!\", name AS \"name!\",\n code_up AS \"code_up!\", code_down\n FROM datatable_migrations\n WHERE code_up LIKE '%--%role%' OR code_down LIKE '%--%role%'\n ORDER BY workspace_id, datatable, timestamp",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "datatable!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "code_up!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "5e63f9b708d669234c600ffcef19f2cd218e8ea441193fb55b6ccf916cf92477"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "63c16a4277983aaed0aed54972923919cee3cc444725ac6b7906922554bae800"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "64d40ff3de929d96a3d03c7d5b363f1284faf50fa3a0c5f4d0beab560d14c7e6"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE owner_workspace_id = $1\n ORDER BY database_key FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "database_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "owner_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "permissions",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "82f7f52f60868a3aa659f2bee6b41f357a09e2c94792542287cbbc810451c47a"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO datatable_database_permissions (database_key, owner_workspace_id, permissions)\n VALUES ($1, $2, $3)\n ON CONFLICT (database_key) DO UPDATE\n SET permissions = EXCLUDED.permissions,\n owner_workspace_id = COALESCE(datatable_database_permissions.owner_workspace_id, EXCLUDED.owner_workspace_id),\n updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "842a5c2694c996897f17e763498bbefaef59a77846cf63e376ba544f6dd9cf65"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE owner_workspace_id = $1 ORDER BY database_key",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "database_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "owner_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "permissions",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "852c7619569dd676b24319267c3ba1ea98aae07a9c2e30de79b3db411e1ef3e9"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id,\n jsonb_each(ws.datatable->'datatables') dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database' = $2\n ORDER BY ws.workspace_id, dt.key",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "deleted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Jsonb"
]
},
"nullable": [
false,
null,
false
]
},
"hash": "8e1be90e8f9f7a4dd067cd9d662ee67ceb2082c94bb2b44f020654a08c6042fc"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name AS \"name!\", code_up AS \"code_up!\", code_down\n FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2\n ORDER BY timestamp",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "code_up!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "91e917b304b39d64782fa632b912e8760a6ed43227326238e0ff20e372bdde17"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE database_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "database_key",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "owner_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "permissions",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "95671af1034d05d58fb6e03e555b35534824748db021100a95eb69078d6e0f74"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND path = $2 AND trigger_kind = 'postgres'::trigger_kind",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9899f094cad88c4eee7ff4dff0a054bf3247a8a83d2edc8104b0c6213c5cf4b5"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock(hashtext('datatable_database_permissions_owner:' || $1))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9deb2c34f0dd9a3c35d48b671b9704fbda8f4429f9c1c97a3f6e6a80d39d335c"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path, trigger_config->>'postgres_resource_path' AS \"postgres_resource_path!\"\n FROM capture_config\n WHERE trigger_kind = 'postgres'::trigger_kind\n AND trigger_config->>'postgres_resource_path' LIKE 'datatable://%'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "postgres_resource_path!",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
null
]
},
"hash": "a05e98be76846ebdb197f002d189060ce2030314e946ca5d6360002cfb1bcbba"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "permissioned_as_email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "a44a4daf82889cfbec961702f7eb83d015a2a6b1b23fcfbd18e7f2ea36e4d8fc"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name AS \"name!\" FROM group_ WHERE workspace_id = $1 AND name = ANY($2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "d4457794adcb511b71dd478bb7c421ac2494716d76af6f2e0847f17224a8e72a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM datatable_database_permissions WHERE database_key = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d51b957f116e7292b3c5bb955e5d1deb08846598ac67edb454c85b7000a2bd0b"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "d5fb5dde6300862f978739a3d9249fc2b3e7697c0da7d3195398933d3d81aadf"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "da5da57ea48ddc6ab271b6c18baa5f7360008e082e2fb8d58faff4461e18c83a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT jsonb_object_keys(datatable->'datatables') FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "jsonb_object_keys",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "e5810ac68c61dd1219ac247e845521837f4db8f653e1e353edb2d6c9ddb7a781"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "ec849eb2e6bf2d5d13566721ea8d92b1aaa9a043706fbcb85e39f1dd94fe91ab"
}
+1 -1
View File
@@ -1 +1 @@
313c572c9dcbcaafd8a1594df4054f9dd26f395c
3579765f3b36a339b9de2a2e8186bcfb681732d9
@@ -0,0 +1 @@
DROP TABLE IF EXISTS datatable_database_permissions;
@@ -0,0 +1,20 @@
-- Role-based access to a data table's database is a property of the Postgres
-- database the roles are created in, not of the config entry that points at it:
-- every entry reaching the same database, in any workspace, resolves to this row.
-- database_key: 'instance:<dbname>' for an instance database, 'pg:<sha256 of
-- host, port and dbname>' for a resource-backed one. The tenants named in
-- `permissions` are principals of owner_workspace_id, and only its admins manage
-- the row. A row whose owner was deleted keeps governing its database with no
-- owner: every role is refused and only a superadmin reaches it, until one opts
-- out or saves it from a workspace that then becomes the owner.
CREATE TABLE datatable_database_permissions (
database_key TEXT PRIMARY KEY,
owner_workspace_id VARCHAR(50) REFERENCES workspace(id) ON DELETE SET NULL,
permissions JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX datatable_database_permissions_owner ON datatable_database_permissions (owner_workspace_id);
GRANT ALL ON datatable_database_permissions TO windmill_user;
GRANT ALL ON datatable_database_permissions TO windmill_admin;
@@ -1542,6 +1542,29 @@ mod tests {
);
}
/// The role selects which postgres login the ATTACH connects as; the asset
/// is still the data table, so it must not leak into the recorded path.
#[test]
fn test_sql_asset_parser_strips_role_from_datatable_ref() {
for (attach, expected) in [
("ATTACH 'datatable://my_dt?role=analyst' AS dt;", "my_dt"),
("ATTACH 'datatable?role=analyst' AS dt;", "main"),
] {
let input = format!("{attach}\nINSERT INTO dt.table1 VALUES ('test');");
let s = parse_assets(&input).map(|s| s.assets);
assert_eq!(
s.map_err(|e| e.to_string()),
Ok(vec![ParseAssetsResult {
kind: AssetKind::DataTable,
path: format!("{expected}/table1"),
access_type: Some(W),
columns: None
},]),
"{attach}"
);
}
}
#[test]
fn test_sql_asset_parser_create_table() {
let input = r#"
@@ -728,10 +728,17 @@ pub fn parse_asset_syntax(
s: &str,
enable_default_syntax: bool,
) -> Option<(AssetKind, Cow<'_, str>)> {
if enable_default_syntax && s == "datatable" {
return Some((AssetKind::DataTable, Cow::Borrowed("main")));
} else if enable_default_syntax && s == "ducklake" {
return Some((AssetKind::Ducklake, Cow::Borrowed("main")));
if enable_default_syntax {
// `datatable` and `datatable?role=<role>` both name the default data
// table; the role picks which postgres login the ATTACH connects as and
// is not part of the asset's identity.
if s.strip_prefix("datatable")
.is_some_and(|rest| rest.is_empty() || rest.starts_with('?'))
{
return Some((AssetKind::DataTable, Cow::Borrowed("main")));
} else if s == "ducklake" {
return Some((AssetKind::Ducklake, Cow::Borrowed("main")));
}
}
for (prefix, kind) in ASSET_KINDS.iter() {
if s.starts_with(prefix) {
@@ -739,6 +746,14 @@ pub fn parse_asset_syntax(
if *kind == AssetKind::Dbt {
return Some((*kind, Cow::Owned(canonicalize_table_asset_path(suffix))));
}
// Same for the explicit form: `datatable://<name>?role=<role>` is
// still the `<name>` data table. Only data tables take a query
// string — for a Resource, `?table=` is part of the path.
if *kind == AssetKind::DataTable {
if let Some((path, _)) = suffix.split_once('?') {
return Some((*kind, Cow::Borrowed(path)));
}
}
// The suffix is kept verbatim. For S3 the path encodes the storage:
// `s3://<storage>/<key>`, with an EMPTY storage segment for the
// workspace default — so `s3:///key` yields `/key` (leading slash
@@ -800,6 +800,16 @@ async fn delete_folder(
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
// A data table role names its tenants by principal, so the name is free
// after this — and recreating a folder with it would inherit every role the
// old one could run as.
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
&w_id,
&format!("f/{name}"),
&mut tx,
)
.await?;
let del = sqlx::query_scalar!(
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1",
name,
+10
View File
@@ -794,6 +794,16 @@ async fn delete_group(
}
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
// A data table role names its tenants by principal, so the name is free
// after the deletes below — and recreating a group with it would inherit
// every role the old one could run as.
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
&w_id,
&format!("g/{name}"),
&mut tx,
)
.await?;
sqlx::query!(
"DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2",
name,
@@ -0,0 +1,832 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
const MAIN_KEY: &str = "instance:dt_main";
/// A data table `main` on the instance database `dt_main`, in `w`.
async fn plant_main(db: &Pool<Postgres>, w: &str) {
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ($1, $2)
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
)
.bind(w)
.bind(json!({
"datatables": {
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } }
}
}))
.execute(db)
.await
.unwrap();
}
/// Permissions on a database, owned by `owner`, with one `analyst` role.
async fn plant_permissions(db: &Pool<Postgres>, key: &str, owner: &str, analyst_tenants: &[&str]) {
sqlx::query(
"INSERT INTO datatable_database_permissions (database_key, owner_workspace_id, permissions)
VALUES ($1, $2, $3)",
)
.bind(key)
.bind(owner)
.bind(json!({ "enabled": true, "roles": {
"admin": { "tenants": [] },
"analyst": { "tenants": analyst_tenants, "pg_rolename": "wm_analyst_x", "pg_password": "pw" }
}}))
.execute(db)
.await
.unwrap();
}
/// Who the `analyst` role of `key` currently lets run as it.
async fn tenants(db: &Pool<Postgres>, key: &str) -> Vec<String> {
let value: serde_json::Value = sqlx::query_scalar(
"SELECT permissions->'roles'->'analyst'->'tenants'
FROM datatable_database_permissions WHERE database_key = $1",
)
.bind(key)
.fetch_one(db)
.await
.unwrap();
serde_json::from_value(value).unwrap()
}
async fn usable_roles(port: u16, w: &str, datatable: &str, token: &str) -> serde_json::Value {
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/{w}/workspaces/datatable_usable_roles/{datatable}"
)),
token,
)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap());
resp.json().await.unwrap()
}
/// A tenant is a name, and a name outlives the principal that held it: whoever
/// takes it next would run as the role it still names. Every route that frees
/// one has to take it off the role, which is easy to miss from any single one of
/// them — so they are pinned together.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn freeing_a_principal_takes_its_datatable_tenant(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
plant_main(&db, "test-workspace").await;
plant_permissions(
&db,
MAIN_KEY,
"test-workspace",
&[
"*",
"u/test-user-2",
"u/test-user-3",
"g/leaving_group",
"f/leaving_folder",
],
)
.await;
for (endpoint, body) in [
("groups/create", json!({ "name": "leaving_group" })),
("folders/create", json!({ "name": "leaving_folder" })),
] {
let resp = authed(client().post(format!("{ws}/{endpoint}")), "SECRET_TOKEN")
.json(&body)
.send()
.await?;
assert_eq!(resp.status(), 200, "{endpoint}: {}", resp.text().await?);
}
let resp = authed(
client().delete(format!("{ws}/groups/delete/leaving_group")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete group: {}", resp.text().await?);
assert!(!tenants(&db, MAIN_KEY)
.await
.contains(&"g/leaving_group".to_string()));
let resp = authed(
client().delete(format!("{ws}/folders/delete/leaving_folder")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete folder: {}", resp.text().await?);
assert!(!tenants(&db, MAIN_KEY)
.await
.contains(&"f/leaving_folder".to_string()));
// Leaving frees the username as surely as an admin removing the member does.
let resp = authed(client().post(format!("{ws}/users/leave")), "SECRET_TOKEN_2")
.send()
.await?;
assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?);
assert!(!tenants(&db, MAIN_KEY)
.await
.contains(&"u/test-user-2".to_string()));
let resp = authed(
client().delete(format!(
"http://localhost:{port}/api/users/delete/test3@windmill.dev"
)),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "global delete: {}", resp.text().await?);
assert!(!tenants(&db, MAIN_KEY)
.await
.contains(&"u/test-user-3".to_string()));
// What no deletion named is left alone — the wildcard above all, which is
// not a principal and cannot be freed.
assert_eq!(tenants(&db, MAIN_KEY).await, vec!["*".to_string()]);
Ok(())
}
/// The permissions are the database's, so a fork's copy of the data table reaches
/// the same roles — evaluated as a member of the workspace that owns them. Being
/// admin of the fork, which any member is of a fork they made, counts for
/// nothing; a superadmin reaches every role from anywhere; and the roles are
/// managed from the owning workspace alone.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_fork_copy_is_evaluated_as_a_member_of_the_owning_workspace(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
plant_main(&db, "test-workspace").await;
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
sqlx::query(
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ('wm-fork-t', 'wm-fork-t', 'test2@windmill.dev', 'test-workspace')",
)
.execute(&db)
.await?;
plant_main(&db, "wm-fork-t").await;
// test-user-2 made the fork and is admin of it, and is no member of the parent.
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
('wm-fork-t', 'test2@windmill.dev', 'test-user-2', true, 'Admin'),
('wm-fork-t', 'test3@windmill.dev', 'test-user-3', false, 'User')",
)
.execute(&db)
.await?;
sqlx::query(
"DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
)
.execute(&db)
.await?;
let fork_admin = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN_2").await;
assert_eq!(fork_admin["enabled"], json!(true));
assert_eq!(fork_admin["roles"], json!([]), "{fork_admin}");
let tenant = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN_3").await;
assert_eq!(tenant["roles"], json!(["analyst"]), "{tenant}");
let superadmin = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN").await;
assert_eq!(
superadmin["roles"],
json!(["admin", "analyst"]),
"{superadmin}"
);
// Managed from the owning workspace: the fork's admin reads them, changes nothing.
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_permissions/main"
)),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let info: serde_json::Value = resp.json().await?;
assert_eq!(info["owner_workspace_id"], json!("test-workspace"));
assert_eq!(info["editable"], json!(false));
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_permissions/main"
)),
"SECRET_TOKEN_2",
)
.json(&json!({ "enabled": true, "roles": [
{ "name": "admin", "tenants": [] }, { "name": "analyst", "tenants": ["u/test-user-2"] }
]}))
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
assert_eq!(status, 401, "{text}");
assert!(
text.contains("managed from workspace 'test-workspace'"),
"{text}"
);
Ok(())
}
/// A resource-backed database is the host, port and database its resource
/// resolves to: entries naming it under other paths and other logins, in other
/// workspaces, reach the same permissions; a resource pointed at another database
/// reaches none.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn entries_reaching_one_database_share_its_permissions(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
VALUES ('other', 'test3@windmill.dev', 'user-three', false, 'User')",
)
.execute(&db)
.await?;
let plant = |w: &'static str, path: &'static str, user: &'static str, dbname: &'static str| {
let db = db.clone();
async move {
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by, edited_at)
VALUES ($1, $2, $3, 'postgresql', 'test-user', now())",
)
.bind(w)
.bind(path)
.bind(json!({ "host": "db.example", "port": 5432, "dbname": dbname, "user": user, "password": "pw" }))
.execute(&db)
.await
.unwrap();
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ($1, $2)
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
)
.bind(w)
.bind(json!({ "datatables": {
"byo": { "database": { "resource_type": "postgresql", "resource_path": path } }
}}))
.execute(&db)
.await
.unwrap();
}
};
plant("test-workspace", "u/test-user/pg", "app", "prod").await;
plant("other", "f/moved/pg", "postgres", "prod").await;
let key = windmill_common::workspaces::datatable_database_key(
&windmill_common::workspaces::DataTableDatabase {
resource_type: windmill_common::workspaces::DataTableCatalogResourceType::Postgresql,
resource_path: "u/test-user/pg".to_string(),
},
&json!({ "host": "db.example", "port": 5432, "dbname": "prod" }),
);
plant_permissions(&db, &key, "test-workspace", &["u/test-user-3"]).await;
// test-user-3 is `test-user-3` in the owning workspace and `user-three` in
// `other`: the tenant is matched where it was written.
let from_other = usable_roles(port, "other", "byo", "SECRET_TOKEN_3").await;
assert_eq!(from_other["enabled"], json!(true));
assert_eq!(from_other["roles"], json!(["analyst"]), "{from_other}");
sqlx::query(
"UPDATE resource SET value = jsonb_set(value, '{dbname}', '\"staging\"')
WHERE workspace_id = 'other' AND path = 'f/moved/pg'",
)
.execute(&db)
.await?;
let elsewhere = usable_roles(port, "other", "byo", "SECRET_TOKEN_3").await;
assert_eq!(elsewhere["enabled"], json!(false), "{elsewhere}");
Ok(())
}
/// The permissions a workspace owns follow it through a change of its id.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_rename_moves_the_permissions_it_owns(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
plant_main(&db, "test-workspace").await;
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
)),
"SECRET_TOKEN",
)
.json(&json!({ "new_id": "renamed-ws", "new_name": "Renamed" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let owner: String = sqlx::query_scalar(
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
)
.bind(MAIN_KEY)
.fetch_one(&db)
.await?;
assert_eq!(owner, "renamed-ws");
let roles = usable_roles(port, "renamed-ws", "main", "SECRET_TOKEN_3").await;
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
Ok(())
}
/// A fork that keeps the original copies the parent's entry verbatim, `forked_from`
/// included when the parent's own entry is a clone. The stamp means "cloned into
/// this workspace's own database" — it is what lets the fork's deletion drop that
/// database — so a copy must not carry one, and the config form may update the
/// schema snapshot inside an existing stamp but never add or remove one.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_kept_original_does_not_inherit_the_clone_stamp(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
sqlx::query(
r#"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = 'test-workspace'"#,
)
.bind(json!({
"datatables": {
"byo": {
"database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" },
"forked_from": { "schema": {} }
}
}
}))
.execute(&db)
.await?;
let resp = authed(
client().post(format!("{ws}/workspaces/create_fork")),
"SECRET_TOKEN",
)
.json(&json!({ "id": "wm-fork-kept", "name": "kept" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let copy: serde_json::Value = sqlx::query_scalar(
"SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = 'wm-fork-kept'",
)
.fetch_one(&db)
.await?;
assert_eq!(copy["database"]["resource_path"], json!("u/test-user/pg"));
assert!(copy.get("forked_from").is_none(), "{copy}");
let save = |w: &'static str, datatables: serde_json::Value| async move {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/{w}/workspaces/edit_datatable_config"
)),
"SECRET_TOKEN",
)
.json(&json!({ "settings": { "datatables": datatables } }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap());
};
let stamp = |w: &'static str| {
let db = db.clone();
async move {
let entry: serde_json::Value = sqlx::query_scalar(
"SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = $1",
)
.bind(w)
.fetch_one(&db)
.await
.unwrap();
entry["forked_from"].clone()
}
};
let byo =
json!({ "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" } });
// The form cannot stamp the copy...
save(
"wm-fork-kept",
json!({ "byo": {
"database": byo["database"], "forked_from": { "schema": {} }
}}),
)
.await;
assert_eq!(stamp("wm-fork-kept").await, serde_json::Value::Null);
// ...nor take the parent's stamp away, and it may update the snapshot inside it.
save("test-workspace", json!({ "byo": byo })).await;
assert_eq!(stamp("test-workspace").await, json!({ "schema": {} }));
save(
"test-workspace",
json!({ "byo": {
"database": byo["database"], "forked_from": { "schema": { "t": ["id"] } }
}}),
)
.await;
assert_eq!(
stamp("test-workspace").await,
json!({ "schema": { "t": ["id"] } })
);
Ok(())
}
/// A save carries the whole role list the drawer loaded, so it can name a tenant
/// another admin's deletion took off the role in between, and it can leave a
/// role stored migrations still carry in their `-- role` annotation undefined.
/// Both are refused rather than written; turning permissions off is not.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_save_names_only_what_exists(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
plant_main(&db, "test-workspace").await;
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user"]).await;
// Spelled the way the parser accepts and a `-- role ` search would miss.
sqlx::query(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down)
VALUES ('test-workspace', 'main', 1, 'add_orders', '--role analyst\nCREATE TABLE orders ()', NULL)",
)
.execute(&db)
.await?;
let preview = |body: serde_json::Value| {
let ws = ws.clone();
async move {
let resp = authed(
client().post(format!(
"{ws}/workspaces/datatable_permissions/main/preview"
)),
"SECRET_TOKEN",
)
.json(&body)
.send()
.await
.unwrap();
(resp.status().as_u16(), resp.text().await.unwrap())
}
};
let (status, text) = preview(json!({ "enabled": true, "roles": [
{ "name": "admin", "tenants": [] },
{ "name": "analyst", "tenants": ["u/test-user", "u/ghost", "g/nobody", "f/nowhere", "*"] }
]}))
.await;
assert_eq!(status, 400, "{text}");
assert!(text.contains("f/nowhere, g/nobody, u/ghost"), "{text}");
let (status, text) = preview(json!({ "enabled": true,
"roles": [{ "name": "admin", "tenants": [] }, { "name": "reader", "tenants": ["u/test-user"] }],
"renames": [{ "from": "analyst", "to": "reader" }]
}))
.await;
assert_eq!(status, 400, "{text}");
assert!(text.contains("'add_orders' (role 'analyst')"), "{text}");
let (status, text) = preview(json!({ "enabled": true,
"roles": [{ "name": "admin", "tenants": [] }]
}))
.await;
assert_eq!(status, 400, "{text}");
assert!(text.contains("'add_orders' (role 'analyst')"), "{text}");
// The roles are the database's: a migration of another entry reaching it,
// here an alias in the same workspace, is stranded all the same.
sqlx::query(
r#"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, '{datatables,alias}',
'{"database": {"resource_type": "instance", "resource_path": "dt_main"}}')
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&db)
.await?;
sqlx::query(
"UPDATE datatable_migrations SET datatable = 'alias' WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
let (status, text) = preview(json!({ "enabled": true,
"roles": [{ "name": "admin", "tenants": [] }]
}))
.await;
assert_eq!(status, 400, "{text}");
assert!(
text.contains("test-workspace/alias: 'add_orders' (role 'analyst')"),
"{text}"
);
sqlx::query("DELETE FROM datatable_migrations WHERE workspace_id = 'test-workspace'")
.execute(&db)
.await?;
// Turning permissions off ignores the submitted roles and is never refused,
// stale tenant or not: it gets as far as the database this test lacks.
let (_, text) = preview(json!({ "enabled": false, "roles": [
{ "name": "admin", "tenants": [] },
{ "name": "analyst", "tenants": ["u/ghost"] }
]}))
.await;
assert!(
!text.contains("no longer exist") && !text.contains("Migration(s)"),
"{text}"
);
// The same save with what exists gets past both checks, to the database this
// test does not have.
let (_, text) = preview(json!({ "enabled": true, "roles": [
{ "name": "admin", "tenants": [] },
{ "name": "analyst", "tenants": ["u/test-user", "*"] }
]}))
.await;
assert!(
!text.contains("no longer exist") && !text.contains("Migration(s)"),
"{text}"
);
Ok(())
}
/// Deleting a workspace takes only the roles it owns with it. A fork that held a
/// copy of the parent's data table owns nothing there, so its deletion leaves the
/// parent's row alone; the owner's own deletion drops its logins but leaves the
/// row, ownerless and closed: every role refused, only a superadmin through.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_deletion_takes_only_the_permissions_it_owns(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
plant_main(&db, "test-workspace").await;
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
sqlx::query(
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ('wm-fork-t', 'wm-fork-t', 'test2@windmill.dev', 'test-workspace')",
)
.execute(&db)
.await?;
plant_main(&db, "wm-fork-t").await;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
('wm-fork-t', 'test2@windmill.dev', 'test-user-2', true, 'Admin'),
('wm-fork-t', 'test3@windmill.dev', 'test-user-3', false, 'User')",
)
.execute(&db)
.await?;
// The fork's owner deletes it: the parent's row is untouched.
let resp = authed(
client().delete(format!(
"http://localhost:{port}/api/workspaces/delete/wm-fork-t"
)),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let owner: Option<String> = sqlx::query_scalar(
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
)
.bind(MAIN_KEY)
.fetch_one(&db)
.await?;
assert_eq!(owner.as_deref(), Some("test-workspace"));
let roles = usable_roles(port, "test-workspace", "main", "SECRET_TOKEN_3").await;
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
// Another workspace reaching the same database, then the owner is deleted.
sqlx::query(
"INSERT INTO workspace (id, name, owner) VALUES ('elsewhere', 'elsewhere', 'test-user')",
)
.execute(&db)
.await?;
plant_main(&db, "elsewhere").await;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
VALUES ('elsewhere', 'test3@windmill.dev', 'test-user-3', true, 'Admin')",
)
.execute(&db)
.await?;
let resp = authed(
client().delete(format!(
"http://localhost:{port}/api/workspaces/delete/test-workspace"
)),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let owner: Option<String> = sqlx::query_scalar(
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
)
.bind(MAIN_KEY)
.fetch_one(&db)
.await?;
assert_eq!(owner, None);
// Admin of `elsewhere`, and the tenant the row names: neither counts without an owner.
let closed = usable_roles(port, "elsewhere", "main", "SECRET_TOKEN_3").await;
assert_eq!(closed["enabled"], json!(true));
assert_eq!(closed["roles"], json!([]), "{closed}");
let superadmin = usable_roles(port, "elsewhere", "main", "SECRET_TOKEN").await;
assert_eq!(
superadmin["roles"],
json!(["admin", "analyst"]),
"{superadmin}"
);
// Nobody but a superadmin manages it now: an admin of `elsewhere` reads it, changes nothing.
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/elsewhere/workspaces/datatable_permissions/main"
)),
"SECRET_TOKEN_3",
)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
assert_eq!(status, 200, "{text}");
let info: serde_json::Value = serde_json::from_str(&text)?;
assert_eq!(info["editable"], json!(false), "{info}");
Ok(())
}
/// An export carries a database's roles and tenants, never its login names or
/// passwords; importing them governs a database nobody governs yet, owned by
/// the importing workspace, with every role refused until a save creates the
/// logins. A database already governed is left alone and reported.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn imported_permissions_govern_without_logins(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('test-workspace', $1)
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
)
.bind(json!({ "datatables": {
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } },
"other": { "database": { "resource_type": "instance", "resource_path": "dt_other" } }
}}))
.execute(&db)
.await?;
plant_permissions(&db, "instance:dt_other", "test-workspace", &["*"]).await;
let import = |rows: serde_json::Value| {
let ws = ws.clone();
async move {
let resp = authed(
client().post(format!("{ws}/workspaces/datatable_permissions_import")),
"SECRET_TOKEN",
)
.json(&rows)
.send()
.await
.unwrap();
let status = resp.status().as_u16();
let text = resp.text().await.unwrap();
(status, text)
}
};
let exported = json!([
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
"admin": { "tenants": [] },
"analyst": { "tenants": ["u/test-user-3"], "pg_rolename": "wm_analyst_x", "pg_password": "leaked?" }
}}},
{ "datatable": "other", "permissions": { "enabled": true, "roles": { "admin": { "tenants": [] } } } }
]);
let (status, text) = import(exported).await;
assert_eq!(status, 200, "{text}");
assert_eq!(text, "[\"other\"]");
// A database is named through a data table of this workspace, never by key.
let (status, text) = import(json!([
{ "datatable": "nope", "permissions": { "enabled": true, "roles": { "admin": { "tenants": [] } } } }
]))
.await;
assert_eq!(status, 404, "{text}");
// An archive's tenant whose principal is gone is refused like a save's would be.
sqlx::query("DELETE FROM datatable_database_permissions WHERE database_key = $1")
.bind(MAIN_KEY)
.execute(&db)
.await?;
let (status, text) = import(json!([
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
"admin": { "tenants": ["u/ghost"] }
}}}
]))
.await;
assert_eq!(status, 400, "{text}");
assert!(text.contains("u/ghost"), "{text}");
let (status, text) = import(json!([
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
"admin": { "tenants": [] },
"analyst": { "tenants": ["u/test-user-3"], "pg_rolename": "wm_analyst_x", "pg_password": "leaked?" }
}}}
]))
.await;
assert_eq!(status, 200, "{text}");
let row: (Option<String>, serde_json::Value) = sqlx::query_as(
"SELECT owner_workspace_id, permissions FROM datatable_database_permissions WHERE database_key = $1",
)
.bind(MAIN_KEY)
.fetch_one(&db)
.await?;
assert_eq!(row.0.as_deref(), Some("test-workspace"));
// A login name is a cluster-wide identifier the next save would rename or
// reset, so it is not taken from an import either.
assert!(
row.1["roles"]["analyst"].get("pg_rolename").is_none(),
"{}",
row.1
);
assert!(
row.1["roles"]["analyst"].get("pg_password").is_none(),
"{}",
row.1
);
// The tenant is listed as usable; resolving the role, which has no login
// yet, gives the caller nothing rather than the owning connection.
let roles = usable_roles(port, "test-workspace", "main", "SECRET_TOKEN_3").await;
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
let resp = authed(
client().get(format!(
"{ws}/workspaces/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=analyst"
)),
"SECRET_TOKEN_3",
)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
assert_eq!(status, 401, "{text}");
assert!(text.contains("has no login yet"), "{text}");
Ok(())
}
/// A clone of a permissioned data table would be a database of its own that
/// nothing governs, readable in full by every member of the fork: refused at the
/// one place a caller cannot go around, the fork creation that wires it in.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_governed_datatable_cannot_be_cloned_into_a_fork(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
plant_main(&db, "test-workspace").await;
plant_permissions(&db, MAIN_KEY, "test-workspace", &["*"]).await;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/create_fork"
)),
"SECRET_TOKEN",
)
.json(&json!({
"id": "wm-fork-clone", "name": "clone",
"forked_datatables": [{ "name": "main", "new_dbname": "wm_fork_clone_main" }]
}))
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
assert_eq!(status, 400, "{text}");
assert!(text.contains("cannot be cloned"), "{text}");
let exists: bool =
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM workspace WHERE id = 'wm-fork-clone')")
.fetch_one(&db)
.await?;
assert!(!exists);
Ok(())
}
+54 -7
View File
@@ -1681,6 +1681,28 @@ async fn delete_user(
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
let mut tx = db.begin().await?;
// A username is scoped to one workspace, and so are the tenants naming it, so
// the memberships are read before anything is deleted. Ordered, and before
// every other write of this transaction — see
// `lock_datatable_permissions_unchecked`.
let memberships = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
&email_to_delete
)
.fetch_all(&mut *tx)
.await?;
for row in &memberships {
// The username is free in that workspace once the rows below are gone, so
// a role still naming it would hand itself to whoever takes it next.
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
&row.workspace_id,
&format!("u/{}", row.username),
&mut tx,
)
.await?;
}
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
@@ -1689,14 +1711,12 @@ async fn delete_user(
.await?;
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?;
let usernames = sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 RETURNING username",
&email_to_delete
)
.fetch_all(&mut *tx)
.await?;
sqlx::query!("DELETE FROM usr WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
for username in usernames {
for row in memberships {
let username = row.username;
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
@@ -2421,6 +2441,18 @@ pub async fn delete_workspace_user_internal(
tx: &mut Transaction<'_, Postgres>,
authed: Option<&ApiAuthed>, // None for system operations
) -> Result<()> {
// ---- Clean up data table role tenants ----
// The username is free once this user's rows are gone, so a tenant left
// behind would hand every role it names to whoever is invited into it next.
// First in the transaction — see `lock_datatable_permissions_unchecked`.
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
w_id,
&format!("u/{username_to_delete}"),
tx,
)
.await?;
// ---- Clean up extra_perms referencing this user ----
let extra_perms_tables = [
"script",
@@ -2578,6 +2610,11 @@ async fn delete_workspace_user(
) -> Result<String> {
let mut tx = db.begin().await?;
// Before the `usr` row below — see `lock_datatable_permissions_unchecked`. The
// removal itself takes this row too; re-acquiring it inside a transaction
// costs nothing.
windmill_common::workspaces::lock_datatable_permissions_unchecked(&mut tx, &w_id).await?;
// Locked so that the authorization below and the delete it guards see the same row.
let target = sqlx::query!(
"SELECT email, is_admin FROM usr where username = $1 AND workspace_id = $2 FOR UPDATE",
@@ -3427,6 +3464,16 @@ async fn leave_workspace(
) -> Result<String> {
forbid_job_token_account_destruction(&authed)?;
let mut tx = db.begin().await?;
// Leaving frees the username here too, and a role that still names it would
// be inherited by the next member to take it.
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
&w_id,
&format!("u/{}", authed.username),
&mut tx,
)
.await?;
sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
&w_id,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Where the ACL planner comes from: the enterprise one, or a refusal.
//!
//! Reading who owns what stays open; every owner change and every grant is the
//! plan this returns, so an edition that is not enterprise cannot make one.
//! `private` alone is not that edition: community builds carry it, so the
//! planner is behind `enterprise` as well.
#[cfg(all(feature = "private", feature = "enterprise"))]
#[allow(unused)]
pub(crate) use crate::datatable_acl_ee::*;
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {
crate::datatable_acl::{AclChange, AclPlan, AclTarget, OwnedObject},
windmill_common::error::{Error, Result},
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn plan_statements(
_target: &AclTarget,
_change: &AclChange,
_dbname: &str,
_pg_role: &str,
_other_pg_roles: &[String],
_existing_objects: &[OwnedObject],
) -> Result<AclPlan> {
Err(Error::BadRequest(
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
))
}
@@ -37,7 +37,7 @@ use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, DebouncingSettings};
use windmill_common::scripts::ScriptLang;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::worker::to_raw_value;
use windmill_common::worker::{to_raw_value, SqlAnnotations};
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
use windmill_common::{PgDatabase, DB};
use windmill_git_sync::{
@@ -128,9 +128,38 @@ async fn datatable_database_arg(
.await?
.ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?;
// No role in the reference: a migration carries its own as a `-- role <name>`
// annotation at the top of its SQL, which the executor reads. Absent, the
// executor falls back to the data table's default role.
Ok(to_raw_value(&format!("datatable://{datatable_name}")))
}
/// Refuse to run a migration whose `-- role` annotation names a role the caller
/// may not use.
///
/// The executor checks this too when it resolves the connection, so this is not
/// the boundary — it is what makes the refusal legible (which migration, which
/// role) and stops it before a job is pushed or a version recorded.
async fn ensure_migration_role_allowed(
db: &DB,
w_id: &str,
datatable_name: &str,
authed: &ApiAuthed,
sql: &str,
timestamp: i64,
name: &str,
) -> Result<()> {
crate::datatable_permissions::ensure_can_use_datatable_role(
db,
w_id,
datatable_name,
SqlAnnotations::datatable_role(sql).as_deref(),
authed,
&format!("Migration {timestamp} ({name})"),
)
.await
}
/// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as
/// the requesting user and labelled `datatable_migration` for traceability, then
/// wait for it. Errors if the job fails.
@@ -426,7 +455,7 @@ async fn run_datatable_migrations(
"all"
};
let mut applied = Vec::new();
let mut to_run = Vec::new();
for m in migrations {
if let Some(only) = query.only {
// Run a single specific migration, skipping every other one.
@@ -440,6 +469,28 @@ async fn run_datatable_migrations(
if applied_versions.contains(&m.timestamp) {
continue;
}
to_run.push(m);
}
// Fail the batch rather than skip: a migration the caller may not run is a
// gap in an ordered sequence, and silently leaving it out would apply later
// ones on top of a schema that never got this change. Checked for the whole
// batch first, so the refusal does not land half way through it.
for m in to_run.iter() {
ensure_migration_role_allowed(
&db,
&w_id,
&datatable_name,
&authed,
&m.code_up,
m.timestamp,
&m.name,
)
.await?;
}
let mut applied = Vec::new();
for m in to_run {
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up)
.await
.map_err(|e| {
@@ -589,6 +640,16 @@ async fn rollback_datatable_migrations(
})?;
let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?;
ensure_migration_role_allowed(
&db,
&w_id,
&datatable_name,
&authed,
&code_down,
version,
&definition.name,
)
.await?;
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &code_down)
.await
.map_err(|e| {
@@ -808,8 +869,7 @@ async fn require_datatable_migrations_manager(db: &DB, authed: &ApiAuthed) -> Re
Ok(())
} else {
Err(Error::BadRequest(
"Only workspace admins and super admins can enable or disable data table migrations"
.to_string(),
"Only workspace admins and super admins can manage data table migrations".to_string(),
))
}
}
@@ -837,6 +897,12 @@ async fn enable_datatable_migrations(
)));
}
// Opting in to migrations is one of the places an admin passes through, and
// an instance database provisioned before the grants carried their options
// cannot hand privileges to the roles permissions create.
crate::datatable_permissions::ensure_instance_db_can_delegate(&db, &w_id, &datatable_name)
.await;
audit_log(
&db,
&authed,
@@ -1179,32 +1245,23 @@ async fn delete_datatable_migration(
Extension(db): Extension<DB>,
Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>,
) -> Result<String> {
// Hold the run-serialization lock across the applied-check and the delete: a
// run snapshots a migration's SQL before recording its version, so an
// unserialized delete could race it and leave `_wm_migrations` pointing at a
// definition that no longer exists (breaking rollback and hiding the applied
// version). Held until the handler returns. Fail closed if we can't verify.
let unreachable = |e| {
Error::internal_err(format!(
"Cannot verify whether migration {} on data table '{}' has already been applied \
(its database is unreachable: {}). Refusing to delete it; retry once the database \
is reachable.",
timestamp, datatable_name, e
))
};
// Hold the run-serialization lock across the delete: a run snapshots a
// migration's SQL before recording its version, so an unserialized delete
// could race it and record a version whose definition is already gone.
// Held until the handler returns.
//
// Deleting one that has already run is allowed: it leaves `_wm_migrations`
// naming a definition that no longer exists, so it can no longer be
// reverted — which is what the caller is warned about before asking.
let lock_client = lock_datatable_migration_runs(&db, &w_id, &datatable_name)
.await
.map_err(unreachable)?;
let applied = read_applied_versions_on_client(&lock_client, &datatable_name)
.await
.map_err(unreachable)?;
if applied.contains(&timestamp) {
return Err(Error::BadRequest(format!(
"Migration {} on data table '{}' has already been applied and cannot be deleted. \
Revert it first.",
timestamp, datatable_name
)));
}
.map_err(|e| {
Error::internal_err(format!(
"Cannot delete migration {} on data table '{}': its database is unreachable \
({}). Retry once the database is reachable.",
timestamp, datatable_name, e
))
})?;
let deleted_name = sqlx::query_scalar!(
"DELETE FROM datatable_migrations \
@@ -1431,6 +1488,11 @@ async fn generate_initial_datatable_migration(
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatableMigration> {
// The snapshot below is taken with the data table's own connection and returns
// every object in it, so it answers to the same gate as turning migrations on
// rather than to whatever roles the caller may run as: a member who is a
// tenant of none would otherwise read the whole schema through it.
require_datatable_migrations_manager(&db, &authed).await?;
validate_datatable_path_segment(&datatable_name)?;
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Where the role planner comes from: the enterprise one, or a refusal.
//!
//! Every change to a data table's Postgres roles — creating, renaming, dropping
//! them — is the plan this returns, so an edition that is not enterprise cannot
//! make one. `private` alone is not that edition: community builds carry it, so
//! the planner is behind `enterprise` as well.
#[cfg(all(feature = "private", feature = "enterprise"))]
#[allow(unused)]
pub(crate) use crate::datatable_permissions_ee::*;
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {
crate::datatable_permissions::{
DefaultAclRule, PgRoleInventory, RolePlan, SetDatatablePermissions,
},
windmill_common::error::{Error, Result},
windmill_common::workspaces::DataTablePermissions,
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn plan_role_changes(
_database_key: &str,
_dbname: &str,
_admin_pg_role: &str,
_old: Option<&DataTablePermissions>,
_req: &SetDatatablePermissions,
_pg_roles: &PgRoleInventory,
_public_schema_is_open: bool,
_default_acl_rules: &[DefaultAclRule],
) -> Result<RolePlan> {
Err(Error::BadRequest(
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
))
}
#[cfg(all(test, not(all(feature = "private", feature = "enterprise"))))]
mod tests {
/// Compiled in every edition that is not enterprise — community builds
/// included, which carry the enterprise sources but must not plan with them.
#[test]
fn a_non_enterprise_build_plans_nothing() {
let plan = super::plan_role_changes(
"acme",
"main",
"db",
"admin",
None,
&serde_json::from_value(serde_json::json!({ "enabled": true })).unwrap(),
&Default::default(),
false,
&[],
);
assert!(plan
.unwrap_err()
.to_string()
.contains("Windmill Enterprise Edition"));
}
}
+12 -2
View File
@@ -1,9 +1,19 @@
pub mod datatable_migrations;
pub mod deployment_requests;
pub mod data_metrics;
pub mod datatable_acl;
pub mod datatable_acl_oss;
pub mod datatable_migrations;
pub mod datatable_permissions;
pub mod datatable_permissions_oss;
pub mod deployment_requests;
pub mod workspaces;
pub mod workspaces_extra;
pub mod workspaces_oss;
// Community builds carry `private`, so the data table planners take the
// enterprise feature too: what they plan is enterprise-only.
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_acl_ee;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_permissions_ee;
#[cfg(feature = "private")]
pub mod workspaces_ee;
+358 -25
View File
@@ -45,10 +45,11 @@ use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
use windmill_common::workspaces::{
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked,
validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable,
DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules,
ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db,
get_datatable_resource_from_db_unchecked, validate_dev_workspace_id,
validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType,
DataTableForkBehavior, DatatableAccess, ProtectionRuleKind, ProtectionRules, ProtectionRuleset,
RuleCheckResult, WorkspaceGitSyncSettings, ADMIN_DATATABLE_ROLE, DEV_WORKSPACE_LOCK_RULE_NAME,
};
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
use windmill_common::PgDatabase;
@@ -141,6 +142,8 @@ pub fn workspaced_service() -> Router {
get(test_datatable_connection),
)
.merge(crate::datatable_migrations::routes())
.merge(crate::datatable_permissions::routes())
.merge(crate::datatable_acl::routes())
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode))
.route("/edit_git_sync_config", post(edit_git_sync_config))
@@ -1150,6 +1153,9 @@ async fn get_public_settings(
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
// Every workspace member reads this one, so the generated role logins go
// through the same redaction as the admin settings and the tarball.
Ok(Json(settings))
}
@@ -2193,8 +2199,28 @@ struct DataTableTables {
datatable_name: String,
/// Hierarchical metadata: schema_name -> table_names
schemas: TableListMap,
/// The schemas the connection's role may create tables in — a subset of
/// `schemas`, since reaching one says nothing about writing to it.
#[serde(default)]
creatable_schemas: Vec<String>,
/// Whether that role may create schemas in the database at all.
#[serde(default)]
can_create_schema: bool,
/// The schemas whose owner this role is a member of, and the tables, as
/// `schema.table`. Only those can have their access changed, so the rest do
/// not offer it.
#[serde(default)]
manageable_schemas: Vec<String>,
#[serde(default)]
manageable_tables: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// The roles the caller may run this data table as. Empty when it has no
/// permissions, which is also what tells the UI it has no role to show.
#[serde(default)]
usable_roles: Vec<String>,
/// The role the caller gets without naming one.
default_role: String,
}
#[derive(Deserialize)]
@@ -2202,6 +2228,8 @@ struct GetDataTableSchemaQuery {
datatable_name: String,
schema_name: String,
table_name: String,
/// Read the columns as this role rather than the data table's default one.
role: Option<String>,
}
#[derive(Serialize, Debug)]
@@ -2248,6 +2276,8 @@ async fn test_datatable_connection(
) -> JsonResult<DataTableConnectionCheck> {
require_admin(authed.is_admin, &authed.username)?;
// The data table's own connection, not whichever role the caller resolves to:
// this reports what the data table itself can do, and the page is admin-only.
let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
@@ -2334,7 +2364,7 @@ async fn test_datatable_connection(
}
async fn list_datatable_schemas(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DataTableSchema>> {
@@ -2342,7 +2372,7 @@ async fn list_datatable_schemas(
let mut results = Vec::new();
for datatable_name in datatable_names {
let schema = match get_datatable_schema(&db, &w_id, &datatable_name).await {
let schema = match get_datatable_schema(&db, &authed, &w_id, &datatable_name).await {
Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None },
Err(e) => DataTableSchema {
datatable_name,
@@ -2356,21 +2386,58 @@ async fn list_datatable_schemas(
Ok(Json(results))
}
/// The role the caller is on, and the data table it belongs to. The tree covers
/// every data table, and a role name means nothing outside the one it was
/// picked on, so it is named rather than applied to all of them.
#[derive(Deserialize)]
struct ListDataTableTablesQuery {
role_for: Option<String>,
role: Option<String>,
}
async fn list_datatable_tables(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<ListDataTableTablesQuery>,
) -> JsonResult<Vec<DataTableTables>> {
let datatable_names = list_datatable_names(&db, &w_id).await?;
let mut roles = list_datatable_roles(&db, &authed, &w_id).await?;
let mut results = Vec::new();
for datatable_name in datatable_names {
let tables = match get_datatable_tables(&db, &w_id, &datatable_name).await {
Ok(schemas) => DataTableTables { datatable_name, schemas, error: None },
let (usable_roles, default_role) = roles
.remove(&datatable_name)
.unwrap_or_else(|| (vec![], ADMIN_DATATABLE_ROLE.to_string()));
// Privileges are the connected role's, so the answer for the data table
// the caller is on has to be read as the role they picked, not as the
// one it defaults to.
let role = query
.role
.as_deref()
.filter(|_| query.role_for.as_deref() == Some(datatable_name.as_str()));
let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name, role).await {
Ok(contents) => DataTableTables {
datatable_name,
schemas: contents.tables,
creatable_schemas: contents.creatable_schemas,
can_create_schema: contents.can_create_schema,
manageable_schemas: contents.manageable_schemas,
manageable_tables: contents.manageable_tables,
error: None,
usable_roles,
default_role,
},
Err(e) => DataTableTables {
datatable_name,
schemas: HashMap::new(),
creatable_schemas: vec![],
can_create_schema: false,
manageable_schemas: vec![],
manageable_tables: vec![],
error: Some(e.to_string()),
usable_roles,
default_role,
},
};
results.push(tables);
@@ -2379,18 +2446,55 @@ async fn list_datatable_tables(
Ok(Json(results))
}
/// Which roles the caller may use on each data table of the workspace, and the
/// one they get by default. The tree lists every data table, so each one is
/// resolved to its database and its permissions read; a data table that does not
/// resolve reports the default alone.
async fn list_datatable_roles(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
) -> Result<HashMap<String, (Vec<String>, String)>> {
let names = list_datatable_names(db, w_id).await?;
let access = DatatableAccess::Authed(authed.to_authed_ref());
let mut roles = HashMap::new();
for name in names {
let record = match windmill_common::workspaces::resolve_datatable_database_unchecked(
db, w_id, &name,
)
.await
{
Ok((_, _, key)) => windmill_common::workspaces::database_permissions_by_key(db, &key)
.await?
.filter(|r| r.permissions.enabled),
Err(_) => None,
};
let info = match record {
Some(record) => (
crate::datatable_permissions::usable_roles(db, w_id, &record, &access).await?,
record.permissions.default_role().to_string(),
),
None => (vec![], ADMIN_DATATABLE_ROLE.to_string()),
};
roles.insert(name, info);
}
Ok(roles)
}
async fn get_datatable_table_schema(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<GetDataTableSchemaQuery>,
) -> JsonResult<DataTableTableSchema> {
let columns = get_datatable_table_columns(
&db,
&authed,
&w_id,
&query.datatable_name,
&query.schema_name,
&query.table_name,
query.role.as_deref(),
)
.await?;
@@ -2402,6 +2506,26 @@ async fn get_datatable_table_schema(
}))
}
/// Resolve a data table for an API caller as the role it defaults to — schema
/// browsing, the database manager and copy-between-databases name none — so a
/// permissioned data table is reachable from the UI only by a tenant of that
/// role (and by workspace admins, who may use any of them).
async fn get_datatable_resource_as_default_role(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
) -> Result<serde_json::Value> {
get_datatable_resource_from_db(
db,
w_id,
datatable_name,
None,
DatatableAccess::Authed(authed.to_authed_ref()),
)
.await
}
async fn list_datatable_names(db: &DB, w_id: &str) -> Result<Vec<String>> {
Ok(sqlx::query_scalar!(
r#"
@@ -2418,9 +2542,15 @@ async fn list_datatable_names(db: &DB, w_id: &str) -> Result<Vec<String>> {
.collect())
}
async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Result<SchemaMap> {
async fn get_datatable_schema(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
) -> Result<SchemaMap> {
// Get the datatable resource (connection credentials)
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
let db_resource =
get_datatable_resource_as_default_role(db, authed, w_id, datatable_name).await?;
// Parse the resource as PgDatabase
let pg_db: PgDatabase = serde_json::from_value(db_resource)
@@ -2444,6 +2574,9 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
FROM pg_namespace
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
AND nspname NOT LIKE 'pg_%'
-- Only what this connection's role can reach: a schema it cannot
-- enter would list no tables and read as an empty one.
AND has_schema_privilege(oid, 'USAGE, CREATE')
ORDER BY nspname
"#,
&[],
@@ -2511,8 +2644,30 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
Ok(schema_map)
}
async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Result<TableListMap> {
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
/// What exists in a data table, and what its role may add to it.
struct DataTableContents {
tables: TableListMap,
creatable_schemas: Vec<String>,
can_create_schema: bool,
manageable_schemas: Vec<String>,
manageable_tables: Vec<String>,
}
async fn get_datatable_tables(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
role: Option<&str>,
) -> Result<DataTableContents> {
let db_resource = get_datatable_resource_from_db(
db,
w_id,
datatable_name,
role,
DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
let (client, connection) = pg_db.connect(Some(db)).await?;
@@ -2526,10 +2681,15 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
let schema_rows = client
.query(
r#"
SELECT nspname::text AS schema_name
SELECT nspname::text AS schema_name,
has_schema_privilege(oid, 'CREATE') AS can_create,
pg_has_role(nspowner, 'USAGE') AS can_manage
FROM pg_namespace
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
AND nspname NOT LIKE 'pg_%'
-- Only what this connection's role can reach: a schema it cannot
-- enter would list no tables and read as an empty one.
AND has_schema_privilege(oid, 'USAGE, CREATE')
ORDER BY nspname
"#,
&[],
@@ -2540,15 +2700,37 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
})?;
let mut table_map: TableListMap = HashMap::new();
let mut creatable_schemas: Vec<String> = Vec::new();
let mut manageable_schemas: Vec<String> = Vec::new();
let schema_names: Vec<String> = schema_rows
.iter()
.map(|row| {
let name: String = row.get(0);
table_map.entry(name.clone()).or_default();
if row.get::<_, bool>(1) {
creatable_schemas.push(name.clone());
}
if row.get::<_, bool>(2) {
manageable_schemas.push(name.clone());
}
name
})
.collect();
let can_create_schema: bool = client
.query_one(
"SELECT has_database_privilege(current_database(), 'CREATE')",
&[],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the database privileges: {}",
pg_error_message(&e)
))
})?
.get(0);
let rows = client
.query(
r#"
@@ -2573,15 +2755,43 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
table_map.entry(table_schema).or_default().push(table_name);
}
Ok(table_map)
// Ownership of a table is its own: a schema you own can hold one you do not.
let manageable_tables = client
.query(
"SELECT n.nspname || '.' || c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1) AND c.relkind = ANY(ARRAY['r','p','v','m','f']::\"char\"[])
AND pg_has_role(c.relowner, 'USAGE')",
&[&schema_names],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read table ownership: {}",
pg_error_message(&e)
))
})?
.into_iter()
.map(|row| row.get::<_, String>(0))
.collect();
Ok(DataTableContents {
tables: table_map,
creatable_schemas,
can_create_schema,
manageable_schemas,
manageable_tables,
})
}
async fn get_datatable_table_columns(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
schema_name: &str,
table_name: &str,
role: Option<&str>,
) -> Result<ColumnMap> {
if is_system_pg_schema(schema_name) {
return Err(Error::BadRequest(format!(
@@ -2590,7 +2800,16 @@ async fn get_datatable_table_columns(
)));
}
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
// Columns are what the connected role may see, so a caller on a role reads
// them as that role.
let db_resource = get_datatable_resource_from_db(
db,
w_id,
datatable_name,
role,
DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
let pg_db: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
let (client, connection) = pg_db.connect(Some(db)).await?;
@@ -2811,9 +3030,48 @@ pub(crate) async fn resolve_pg_source_checked(
authed: &ApiAuthed,
w_id: &str,
source: &str,
) -> Result<PgDatabase> {
resolve_pg_source_as(db, user_db, authed, w_id, source, false).await
}
/// As [`resolve_pg_source_checked`], but a workspace admin reaches a data table
/// as `admin` rather than as the role it defaults to.
///
/// For the paths that copy a whole database: dumping as a restricted default
/// role silently leaves out every table that role cannot read, which is a
/// truncated copy rather than an error. A non-admin still resolves as their own
/// role — `admin` is not theirs to ask for — so this hands out nothing.
pub(crate) async fn resolve_pg_source_for_copy(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
source: &str,
) -> Result<PgDatabase> {
resolve_pg_source_as(db, user_db, authed, w_id, source, authed.is_admin).await
}
async fn resolve_pg_source_as(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
source: &str,
as_admin: bool,
) -> Result<PgDatabase> {
let db_resource = if let Some(name) = source.strip_prefix("datatable://") {
get_datatable_resource_from_db_unchecked(db, w_id, name).await?
if as_admin {
get_datatable_resource_from_db(
db,
w_id,
name,
Some(ADMIN_DATATABLE_ROLE),
DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?
} else {
get_datatable_resource_as_default_role(db, authed, w_id, name).await?
}
} else if let Some(path) = source.strip_prefix("$res:") {
let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed(
authed,
@@ -2871,7 +3129,8 @@ pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Re
.unwrap_or(false))
}
/// Same, for the `datatable://<name>` / `$res:<path>` form the import endpoints take.
/// Same as [`is_instance_datatable`], for the `datatable://<name>` / `$res:<path>` form the
/// import endpoints take.
async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result<bool> {
match source.strip_prefix("datatable://") {
Some(name) => is_instance_datatable(db, w_id, name).await,
@@ -3198,6 +3457,9 @@ async fn create_pg_database(
Json(req): Json<CreatePgDatabaseRequest>,
) -> Result<String> {
windmill_common::validate_dbname(&req.target_dbname)?;
if let Some(name) = req.source.strip_prefix("datatable://") {
crate::datatable_permissions::refuse_clone_of_governed_datatable(&db, &w_id, name).await?;
}
// Non-superadmin: restrict dbname to wm_fork_ prefix
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
@@ -3290,11 +3552,18 @@ async fn import_pg_database(
}
let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly;
let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
let source_pg = resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.source).await?;
let mut target_pg =
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.target).await?;
if let Some(ref override_dbname) = req.target_dbname_override {
// Only the fork clone flow overrides the target database name; a plain
// database-to-database import is an admin moving data between databases
// they already reach.
if let Some(name) = req.source.strip_prefix("datatable://") {
crate::datatable_permissions::refuse_clone_of_governed_datatable(&db, &w_id, name)
.await?;
}
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
if !override_dbname.starts_with("wm_fork_") {
return Err(Error::BadRequest(
@@ -3484,14 +3753,14 @@ async fn edit_datatable_config(
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
let mut tx = db.begin().await?;
let old_datatables: HashMap<String, DataTable> = serde_json::from_value(
sqlx::query_scalar!(
"SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1",
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
&w_id
)
.fetch_one(&db)
.fetch_optional(&mut *tx)
.await?
.flatten()
.unwrap_or(serde_json::Value::Null),
)
.unwrap_or_default();
@@ -3544,9 +3813,23 @@ async fn edit_datatable_config(
Some(true)
}
};
// `forked_from` is stamped by the fork clone and read by the fork's
// database drop as "this entry has a database of its own": the form may
// update the schema snapshot inside it, never add or remove the stamp.
let old = old_datatables.get(lookup);
dt.forked_from = match (
old.and_then(|old| old.forked_from.as_ref()),
dt.forked_from.take(),
) {
(None, _) => None,
(Some(_), Some(new)) => Some(new),
(Some(old), None) => Some(windmill_common::workspaces::DataTableForkedFrom {
schema: old.schema.clone(),
}),
};
}
let args_for_audit = format!("{:?}", new_config.settings);
let args_for_audit = serde_json::to_string(&new_config.settings).unwrap_or_default();
audit_log(
&mut *tx,
&authed,
@@ -7448,6 +7731,10 @@ async fn apply_forked_datatable(
fdt: &ForkedDatatableInfo,
) -> Result<()> {
windmill_common::validate_dbname(&fdt.new_dbname)?;
// The clone endpoints refuse this too; this is the one a caller cannot go
// around, since it is what wires the fork's config to the copied database.
crate::datatable_permissions::refuse_clone_of_governed_datatable(db, parent_w_id, &fdt.name)
.await?;
if !fdt.new_dbname.starts_with("wm_fork_") {
return Err(Error::BadRequest(format!(
"Forked datatable database name '{}' must start with 'wm_fork_'",
@@ -7973,6 +8260,31 @@ async fn create_workspace_fork(
apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?;
}
// A copy that was not forked here loses any `forked_from` it inherited: the stamp
// means "cloned into this workspace's own database" — it is what lets the fork's
// deletion drop that database — and a copy of the parent's clone points where the
// parent points.
let forked_datatable_names: Vec<String> = nw
.forked_datatables
.iter()
.map(|f| f.name.clone())
.collect();
sqlx::query!(
r#"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, '{datatables}', (
SELECT COALESCE(jsonb_object_agg(
key,
CASE WHEN key = ANY($2) THEN value ELSE value - 'forked_from' END
), '{}'::jsonb)
FROM jsonb_each(datatable->'datatables')
))
WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'"#,
&forked_id,
&forked_datatable_names[..],
)
.execute(&mut *tx)
.await?;
// The settings clone copies the source's ducklake config verbatim — including a parent
// fork's own `fork_behavior` stamps. Sharing is a per-fork-creation choice, never
// inherited: reset any cloned stamps first, then apply this fork's requested list.
@@ -8775,6 +9087,27 @@ async fn leave_workspace(
) -> Result<String> {
windmill_api_auth::forbid_job_token_account_destruction(&authed)?;
let mut tx = db.begin().await?;
// Leaving frees the username here, and a role that still names it would be
// inherited by the next member to take it. Read from the row rather than
// from `authed`, whose username is cached and survives a rename: the name
// that is about to be free is the one the row holds.
let left = sqlx::query_scalar!(
"SELECT username FROM usr WHERE workspace_id = $1 AND email = $2",
&w_id,
&authed.email
)
.fetch_all(&mut *tx)
.await?;
for username in &left {
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
&w_id,
&format!("u/{username}"),
&mut tx,
)
.await?;
}
sqlx::query!(
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
&w_id,
@@ -62,6 +62,11 @@ pub(crate) async fn change_workspace_id(
// rename moves the chain from one to the other.
crate::workspaces::lock_dev_pairing(&mut tx, &[&old_id, &rw.new_id]).await?;
// The permissions this workspace owns move to the new id below, so a save of them
// holds still meanwhile. Pairing lock first, as `lock_datatable_permissions_unchecked`
// states.
windmill_common::workspaces::lock_datatable_permissions_unchecked(&mut tx, &old_id).await?;
check_w_id_conflict(&mut tx, &rw.new_id).await?;
info!(
@@ -120,6 +125,16 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
// The permissions of every database this workspace turned on follow it: their tenants
// are its principals, which the rows below move too.
sqlx::query!(
"UPDATE datatable_database_permissions SET owner_workspace_id = $1 WHERE owner_workspace_id = $2",
&rw.new_id,
&old_id,
)
.execute(&mut *tx)
.await?;
// The managed git-sync webhooks deliver to /api/w/{old_id}/... — a URL the
// renamed workspace no longer answers on (the old id is archived and the
// receiver skips it). Strip the webhook fields from the new row so polling
@@ -978,6 +993,35 @@ pub(crate) async fn delete_workspace(
vec![]
});
// Same shape, same reason: the logins behind the workspace's data tables live in
// databases that go with it, so what to drop is resolved while the config is here
// and the drop itself runs after the commit. Nothing is dropped here.
// The first rows this transaction locks — see `lock_datatable_permissions_unchecked`.
// Without it a permissions save that commits between this read and the rows'
// deletion adds a login nothing then drops.
let mut planned_role_drops = Vec::new();
windmill_common::workspaces::lock_datatable_permissions_unchecked(&mut tx, &w_id).await?;
let datatable_config = sqlx::query_scalar!(
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
for name in datatable_config
.as_ref()
.and_then(|c| c.get("datatables"))
.and_then(|d| d.as_object())
.map(|d| d.keys().map(|k| k.to_string()).collect::<Vec<String>>())
.unwrap_or_default()
{
if let Some(planned) =
crate::datatable_permissions::plan_drop_of_datatable_roles(&db, &w_id, &name).await
{
planned_role_drops.push((name, planned));
}
}
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
@@ -1241,6 +1285,20 @@ pub(crate) async fn delete_workspace(
);
}
// The workspace that owned these roles is gone. Their logins go with it; the
// permissions rows stay, ownerless — every entry still reaching those databases
// finds every role refused rather than the owning connection.
for (name, planned) in planned_role_drops {
let key = planned.2.clone();
crate::datatable_permissions::run_planned_drop_keeping_record(
&db, &w_id, &name, None, planned,
)
.await;
if let Err(e) = crate::datatable_permissions::restart_triggers_reaching(&db, &key).await {
tracing::error!("Could not restart the triggers replicating {key}: {e:#}");
}
}
if let Some(parent) = dev_lock_parent {
windmill_common::workspaces::invalidate_protection_rules_cache(&parent);
}
@@ -1297,6 +1355,48 @@ pub struct DropForkedDatatableDatabasesRequest {
datatable_names: Vec<String>,
}
/// Take the generated logins of a data table's database away before that
/// database goes: a login is cluster-wide, so it would outlive the database it
/// was created in. The permissions row stays until the drop succeeds — with its
/// logins gone every role is refused meanwhile — and is forgotten by the caller
/// once the database is. Returns the key of that database's permissions when
/// this workspace owns them and nothing stands in the way of forgetting them:
/// a row with no logins to drop included, or the database's next namesake would
/// find it governed by a workspace that is gone.
async fn drop_datatable_roles_before_its_database(
db: &DB,
w_id: &str,
dt_name: &str,
) -> Option<String> {
let (_, _, key) =
windmill_common::workspaces::resolve_datatable_database_unchecked(db, w_id, dt_name)
.await
.ok()?;
let record = windmill_common::workspaces::database_permissions_by_key(db, &key)
.await
.ok()
.flatten()?;
if record.owner_workspace_id.as_deref() != Some(w_id) {
return None;
}
if let Some(planned) =
crate::datatable_permissions::plan_drop_of_datatable_roles(db, w_id, dt_name).await
{
if !crate::datatable_permissions::run_planned_drop_keeping_record(
db,
w_id,
dt_name,
Some(w_id),
planned,
)
.await
{
return None;
}
}
Some(key)
}
/// Drop forked datatable databases. Returns errors per datatable that failed.
/// Same permission as delete_workspace: fork owner or super admin.
pub async fn drop_forked_datatable_databases(
@@ -1359,11 +1459,18 @@ pub async fn drop_forked_datatable_databases(
));
continue;
}
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
errors.push(format!(
let permissions_key =
drop_datatable_roles_before_its_database(&db, &w_id, dt_name).await;
match windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
Ok(()) => {
if let Some(key) = permissions_key {
crate::datatable_permissions::forget_database_permissions(&db, &key).await;
}
}
Err(e) => errors.push(format!(
"Could not drop instance database '{}' for datatable://{}: {}",
db_to_drop, dt_name, e
));
)),
}
} else {
let fork_pg = match crate::workspaces::resolve_pg_source_checked(
@@ -1424,14 +1531,24 @@ pub async fn drop_forked_datatable_databases(
match parent_pg.connect(Some(&db)).await {
Ok((client, connection)) => {
let join_handle = tokio::spawn(async move { connection.await });
if let Err(e) = client
let permissions_key =
drop_datatable_roles_before_its_database(&db, &w_id, dt_name).await;
match client
.execute(&format!("DROP DATABASE \"{}\"", db_to_drop), &[])
.await
{
errors.push(format!(
Ok(_) => {
if let Some(key) = permissions_key {
crate::datatable_permissions::forget_database_permissions(
&db, &key,
)
.await;
}
}
Err(e) => errors.push(format!(
"Could not drop database '{}' for datatable://{}: {}",
db_to_drop, dt_name, e
));
)),
}
drop(client);
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
@@ -1996,8 +2113,11 @@ async fn is_workspace_owner(
/// `parent_workspace_id`, but a `wm-fork-` workspace can outlive its parent (the FK is
/// `ON DELETE SET NULL`), so also treat the prefix as fork-ness — otherwise an orphaned fork would
/// lose owner-self-delete. Used to gate owner-self-delete, which is permitted for forks/dev
/// workspaces but requires superadmin otherwise.
async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
/// workspaces but requires superadmin otherwise, and to refuse a data table opt-in from a fork.
///
/// Authorization: performs none. It reads the lineage of any `w_id` it is handed, so callers
/// MUST already have authorized the caller for that workspace.
pub(crate) async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
if w_id.starts_with(WM_FORK_PREFIX) {
return Ok(true);
}
+475 -1
View File
@@ -4938,6 +4938,18 @@ paths:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: role_for
in: query
required: false
schema:
type: string
description: the data table the `role` below was picked on
- name: role
in: query
required: false
schema:
type: string
description: read that data table's privileges as this role rather than its default
responses:
"200":
description: table metadata of all datatables
@@ -4971,6 +4983,12 @@ paths:
required: true
schema:
type: string
- name: role
in: query
required: false
description: read the columns as this role rather than the data table's default one
schema:
type: string
responses:
"200":
description: schema of one datatable table
@@ -5186,6 +5204,260 @@ paths:
error:
type: string
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}:
get:
summary: get a datatable's role-based permissions (admins only)
operationId: getDatatablePermissions
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: datatable permissions
content:
application/json:
schema:
$ref: "#/components/schemas/DatatablePermissions"
post:
summary: set a datatable's role-based permissions, running the corresponding role SQL (admins only)
operationId: setDatatablePermissions
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SetDatatablePermissions"
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
get:
summary: list the datatable roles the caller may run as
operationId: listUsableDatatableRoles
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: usable roles
content:
application/json:
schema:
type: object
required: [enabled, roles, default_role]
properties:
enabled:
type: boolean
roles:
type: array
items:
type: string
default_role:
type: string
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}/preview:
post:
summary: preview the SQL a datatable permissions change would run (admins only)
operationId: previewDatatablePermissions
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SetDatatablePermissions"
responses:
"200":
description: statements that would be run, in a single transaction
content:
application/json:
schema:
type: object
required: [statements, warnings]
properties:
statements:
type: array
items:
type: string
warnings:
type: array
items:
type: string
/w/{workspace}/workspaces/datatable_permissions_import:
post:
summary: restore exported datatable permissions on the databases the named data tables reach and nobody governs yet (admins only); passwords are not restored, so every role is refused until saved again
operationId: importDatatablePermissions
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
required: [datatable, permissions]
properties:
datatable:
description: a data table of this workspace reaching the database
type: string
permissions:
type: object
responses:
"200":
description: the data tables whose database was already governed and left as it was
content:
application/json:
schema:
type: array
items:
type: string
/w/{workspace}/workspaces/datatable_acl/{datatable_name}:
get:
summary: read the owner and grants of a datatable schema or table
operationId: getDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
- name: kind
in: query
required: true
schema:
type: string
enum: [database, schema, table]
- name: schema
in: query
required: false
schema:
type: string
- name: table
in: query
required: false
schema:
type: string
- name: role
in: query
required: false
schema:
type: string
description: read as this role rather than the caller's default one
responses:
"200":
description: owner and grants
content:
application/json:
schema:
$ref: "#/components/schemas/DatatableAclInfo"
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan:
post:
summary: preview the SQL an ownership or grant change would run (a non-admin may act on what their role owns)
operationId: planDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AclChangeRequest"
responses:
"200":
description: statements that would be run, in a single transaction
content:
application/json:
schema:
type: object
required: [statements, warnings]
properties:
statements:
type: array
items:
type: string
warnings:
type: array
items:
type: string
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply:
post:
summary: run an ownership or grant change (a non-admin may act on what their role owns)
operationId: applyDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AclChangeRequest"
responses:
"200":
description: change applied
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/enable_datatable_migrations/{datatable_name}:
post:
summary: opt a datatable in to migrations (admins / super admins only)
@@ -33965,6 +34237,63 @@ components:
- ran
- not_run
- unknown
DatatableRoleInfo:
type: object
required: [name, tenants]
properties:
name:
type: string
tenants:
description: who may run as this role, as '*' (everyone), u/<user>, g/<group> or f/<folder>
type: array
items:
type: string
pg_rolename:
description: the underlying postgres role, absent for admin
type: string
DatatablePermissions:
type: object
required: [enabled, roles, default_role, editable]
properties:
enabled:
type: boolean
roles:
type: array
items:
$ref: "#/components/schemas/DatatableRoleInfo"
default_role:
description: the role a script gets when it names none
type: string
owner_workspace_id:
description: the workspace that turned the permissions on, whose admins manage them and whose principals the tenants are; absent while they are off
type: string
editable:
description: whether the caller may change them from this workspace (an admin of the owning workspace, or a superadmin)
type: boolean
SetDatatablePermissions:
type: object
required: [enabled, roles]
properties:
enabled:
type: boolean
roles:
type: array
items:
$ref: "#/components/schemas/DatatableRoleInfo"
default_role:
description: the role a script gets when it names none; absent means admin
type: string
renames:
description: role renames (old -> new), so a rename keeps the postgres role and its grants
type: array
items:
type: object
required: [from, to]
properties:
from:
type: string
to:
type: string
DataTableSchema:
type: object
required: [datatable_name, schemas]
@@ -33986,9 +34315,128 @@ components:
error:
type: string
AclTarget:
type: object
required: [kind]
properties:
kind:
type: string
enum: [database, schema, table]
schema:
type: string
description: required for a schema or table target
table:
type: string
AclChange:
type: object
required: [type]
properties:
type:
type: string
enum: [set_owner, grant, revoke]
role:
type: string
privileges:
type: array
items:
type: string
scope:
type: string
enum:
[
target,
all_tables,
all_sequences,
all_functions,
future_tables,
future_sequences,
future_functions
]
objects:
type: array
description: objects inside the target a revoke covers, empty for the target itself
items:
$ref: "#/components/schemas/AclObject"
AclChangeRequest:
type: object
required: [target, change]
properties:
target:
$ref: "#/components/schemas/AclTarget"
change:
$ref: "#/components/schemas/AclChange"
role:
type: string
description: act as this role rather than the caller's default one
AclObject:
type: object
required: [name, kind]
properties:
name:
type: string
kind:
type: string
description: TABLE, SEQUENCE, ... — the keyword a REVOKE on it takes
args:
type: string
description: identity arguments of a routine, which is what tells two of the same name apart
AclGrant:
type: object
required: [grantee, privileges]
properties:
grantee:
type: string
privileges:
type: array
items:
type: string
object:
$ref: "#/components/schemas/AclObject"
future:
type: string
description: set for a default privilege, naming the kind of object it covers
DatatableAclInfo:
type: object
required:
[owner, roles, usable_roles, grants, supports_maintain, dbname, can_manage, current_role]
properties:
owner:
type: string
roles:
type: array
description: every role of the data table; the list is not private, only what each may reach
items:
type: string
usable_roles:
type: array
description: the roles the caller may themselves run as
items:
type: string
can_manage:
type: boolean
description: whether the caller's role may change this target at all
current_role:
type: string
description: the role the caller is connected as
supports_maintain:
type: boolean
description: whether the server is Postgres 17+, which added the MAINTAIN table privilege
dbname:
type: string
description: the database the target lives in
grants:
type: array
items:
$ref: "#/components/schemas/AclGrant"
DataTableTables:
type: object
required: [datatable_name, schemas]
required: [datatable_name, schemas, default_role]
properties:
datatable_name:
type: string
@@ -34001,6 +34449,32 @@ components:
type: string
error:
type: string
creatable_schemas:
type: array
description: the schemas the caller's role may create tables in
items:
type: string
can_create_schema:
type: boolean
description: whether that role may create schemas in the database
manageable_schemas:
type: array
description: the schemas whose access that role may change
items:
type: string
manageable_tables:
type: array
description: the tables whose access that role may change, as schema.table
items:
type: string
usable_roles:
type: array
description: roles the caller may run this data table as, empty when it has no permissions
items:
type: string
default_role:
type: string
description: the role the caller gets without naming one
DataTableTableSchema:
type: object
+34 -1
View File
@@ -518,8 +518,10 @@ pub(crate) async fn offboard_global_user(
require_super_admin(&db, &authed).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
// Ordered, because the offboarding below takes each workspace's settings row:
// every path that takes more than one takes them in this sequence.
let workspaces = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1",
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
&email
)
.fetch_all(&db)
@@ -830,6 +832,18 @@ async fn offboard_user_from_workspace<'c>(
reassign_to: &str,
new_permissioned_as: &str,
) -> Result<OffboardSummary> {
// Before this transaction locks anything else — see
// `lock_datatable_permissions_unchecked`. Everything below reassigns rows a
// rename or a deletion writes while holding those.
windmill_common::workspaces::lock_datatable_permissions_unchecked(tx, w_id).await?;
let datatable_settings = sqlx::query_scalar!(
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
w_id
)
.fetch_optional(&mut **tx)
.await?
.flatten();
let new_prefix = reassign_to.to_string();
let departing = windmill_common::users::username_to_permissioned_as(username);
@@ -986,6 +1000,25 @@ async fn offboard_user_from_workspace<'c>(
.await?
.unwrap_or(0);
// A data table names its database by resource path, so one just moved has to
// move in the config too: left behind it stops resolving, and the path it
// named is free for a resource pointing somewhere else entirely.
if let Some(mut settings) = datatable_settings {
if windmill_common::workspaces::move_datatable_resource_paths(
&mut settings,
&format!("u/{username}/"),
&format!("{new_prefix}/"),
) {
sqlx::query!(
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
settings,
w_id
)
.execute(&mut **tx)
.await?;
}
}
// ---- eval datasets ----
// The foreign keys cascade the rename onto cases and experiments; the paths held inside JSONB
// (an experiment's subject, a dataset's scorers) are rewritten separately since the cascade
+79 -24
View File
@@ -261,16 +261,10 @@ async fn rename_user(
)));
}
sqlx::query!(
"UPDATE password SET username = $1 WHERE email = $2",
ru.new_username,
user_email
)
.execute(&mut *tx)
.await?;
// Ordered, so a rename and a deletion that touch the same workspaces take
// their settings rows in the same sequence rather than head-on.
let workspace_usernames = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1",
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
&user_email
)
.fetch_all(&mut *tx)
@@ -291,6 +285,16 @@ async fn rename_user(
.await?;
}
// After the settings rows above: the deletion paths take `password` while
// holding one, so taking it first here would be the other order.
sqlx::query!(
"UPDATE password SET username = $1 WHERE email = $2",
ru.new_username,
user_email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -316,19 +320,49 @@ async fn update_username_in_workpsace<'c>(
new_username: &str,
w_id: &str,
) -> error::Result<()> {
// Before anything else in the transaction — see
// `lock_datatable_permissions_unchecked`. `rename_user` walks memberships in
// `workspace_id` order, so a rename spanning workspaces takes their rows in
// that order too.
windmill_common::workspaces::lock_datatable_permissions_unchecked(tx, w_id).await?;
let datatable_settings = sqlx::query_scalar!(
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
w_id
)
.fetch_optional(&mut **tx)
.await?
.flatten();
// ---- instance and workspace users ----
// Scoped to this workspace, like everything else here: `usr` and
// `usr_to_group` rows of another workspace answer to that workspace's own
// settings row.
sqlx::query!(
"UPDATE usr SET username = $1 WHERE email = $2",
"UPDATE usr SET username = $1 WHERE email = $2 AND workspace_id = $3",
new_username,
email
email,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE usr_to_group SET usr = $1 WHERE usr = $2",
"UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3",
new_username,
old_username
old_username,
w_id
)
.execute(&mut **tx)
.await?;
// ---- group_ ----
sqlx::query!(
"UPDATE group_ SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
@@ -813,17 +847,6 @@ async fn update_username_in_workpsace<'c>(
.execute(&mut **tx)
.await?;
// ---- group_ ----
sqlx::query!(
"UPDATE group_ SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
// ---- folders ----
sqlx::query!(
@@ -921,6 +944,38 @@ async fn update_username_in_workpsace<'c>(
.execute(&mut **tx)
.await?;
// ---- data table role tenants and backing resources ----
// Who may run as a data table role is stored as `u/<username>`, and the
// executor compares it against the caller's name. Left behind, the rename
// takes the role away from the user it followed and hands it to whoever
// takes the old name next.
windmill_common::workspaces::rename_datatable_tenant_in_workspace_unchecked(
w_id,
&format!("u/{old_username}"),
&format!("u/{new_username}"),
tx,
)
.await?;
if let Some(mut settings) = datatable_settings {
// The resource rewrite above moves a data table's own postgres resource
// with everything else the user owns; the config names it by path, so it
// has to travel too.
if windmill_common::workspaces::move_datatable_resource_paths(
&mut settings,
&format!("u/{old_username}/"),
&format!("u/{new_username}/"),
) {
sqlx::query!(
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
settings,
w_id
)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
@@ -1759,6 +1759,58 @@ pub(crate) async fn tarball_workspace(
}
}
// The permissions of the databases this workspace governs, as the import
// endpoint takes them back: named by a data table of this workspace reaching
// the database, with roles and tenants — no passwords, which are direct
// database logins, and no login names, which a re-save generates. A database
// no entry of the workspace reaches any more cannot be named, and is left out.
// Admin-only, like the settings and like the drawer that shows the same thing.
let permissions = if include_settings.unwrap_or(false) {
windmill_common::workspaces::database_permissions_owned_by(&mut *tx, &w_id).await?
} else {
vec![]
};
if !permissions.is_empty() {
let mut reaching: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let datatables: Vec<String> = sqlx::query_scalar!(
"SELECT jsonb_object_keys(datatable->'datatables') FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_all(&mut *tx)
.await?
.into_iter()
.flatten()
.collect();
for name in datatables {
if let Ok((_, _, key)) =
windmill_common::workspaces::resolve_datatable_database_unchecked(&db, &w_id, &name)
.await
{
reaching.entry(key).or_insert(name);
}
}
let exported: Vec<serde_json::Value> = permissions
.into_iter()
.filter_map(|mut row| {
let datatable = reaching.get(&row.database_key)?;
for role in row.permissions.roles.values_mut() {
role.pg_rolename = None;
role.pg_password = None;
}
Some(serde_json::json!({
"datatable": datatable,
"permissions": row.permissions,
}))
})
.collect();
let json = serde_json::to_string_pretty(&exported)
.map_err(|e| Error::internal_err(format!("serializing permissions: {e}")))?;
archive
.write_to_archive(&json, "datatable_permissions.json")
.await?;
}
archive.finish().await?;
let file = tokio::fs::File::open(&file_path).await?;
+64 -11
View File
@@ -1482,6 +1482,69 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
Ok(())
}
/// What `custom_instance_user` gets on an instance database.
///
/// `WITH GRANT OPTION` throughout: data table permissions have Windmill connect
/// as this role and hand privileges to the Postgres roles it creates, and a
/// privilege it cannot pass on is one those roles can never receive. The
/// database and schema `public` are owned by the instance's own Postgres user,
/// not by this one, so re-granting is the only way it can reach them.
/// The grants naming schema `public` are conditional on it being there: dropping
/// it is ordinary hardening, and a GRANT on a schema that is absent is an error
/// that would take the whole repair — and every grant after it — down with it.
fn instance_db_grants(dbname: &str) -> String {
format!(
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') THEN
GRANT USAGE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION;
GRANT CREATE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;
END IF;
END $$;"
)
}
/// Re-apply those grants to an instance database that already exists.
///
/// One provisioned before the grant options were added holds the privileges
/// without them, and only the instance's own Postgres user — the connection
/// this runs on — can add them: a role cannot give itself a grant option it
/// does not hold.
///
/// Authorization: performs none, and this connects as the instance database's
/// owner to change privileges. Callers MUST have authorized the operation the
/// grants are for, and MUST pass a `dbname` they resolved from a data table's
/// own config rather than one a request named — every caller today is a data
/// table path that read it from `workspace_settings`.
pub async fn ensure_instance_db_grant_options_unchecked(
db: &DB,
dbname: &str,
) -> error::Result<()> {
let dbname = dbname.trim();
validate_dbname(dbname)?;
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
let creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds };
let (client, connection) = creds.connect(Some(db)).await?;
let join_handle = tokio::spawn(async move { connection.await });
let result = client.batch_execute(&instance_db_grants(dbname)).await;
drop(client);
shutdown_pg_connection(join_handle).await?;
result.map_err(|e| {
error::Error::internal_err(format!(
"Failed to grant permissions on '{}': {}",
dbname,
crate::error::pg_error_message(&e)
))
})
}
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake").
pub async fn create_custom_instance_database(
@@ -1521,17 +1584,7 @@ pub async fn create_custom_instance_database(
let (client, connection) = new_pg_creds.connect(Some(db)).await?;
let join_handle = tokio::spawn(async move { connection.await });
if let Err(e) = client
.batch_execute(&format!(
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user;
GRANT USAGE ON SCHEMA public TO custom_instance_user;
GRANT CREATE ON SCHEMA public TO custom_instance_user;
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;"
))
.await
{
if let Err(e) = client.batch_execute(&instance_db_grants(dbname)).await {
tracing::warn!(
"Failed to grant permissions on '{}': {}. Continuing.",
dbname,
+34 -2
View File
@@ -329,6 +329,7 @@ pub fn try_expand_internal_db_query(
"ALTER_TABLE" => expand_alter_table(json_str, db_type).map(ExpandedQuery::sql),
"CREATE_SCHEMA" => expand_create_schema(json_str, db_type).map(ExpandedQuery::sql),
"DROP_SCHEMA" => expand_drop_schema(json_str, db_type).map(ExpandedQuery::sql),
"RENAME_SCHEMA" => expand_rename_schema(json_str, db_type).map(ExpandedQuery::sql),
// Metadata queries
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
@@ -1716,6 +1717,13 @@ struct DropSchemaPayload {
ducklake: Option<String>,
}
#[derive(Deserialize)]
struct RenameSchemaPayload {
schema: String,
new_schema: String,
ducklake: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableEditorColumn {
name: String,
@@ -2004,6 +2012,17 @@ fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result<String, String>
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
}
fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
let p: RenameSchemaPayload = serde_json::from_str(json_str)
.map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?;
let query = format!(
"ALTER SCHEMA {} RENAME TO {};",
qi(&p.schema, db_type),
qi(&p.new_schema, db_type)
);
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
}
fn expand_create_table(json_str: &str, db_type: DbType) -> Result<String, String> {
let p: CreateTablePayload = serde_json::from_str(json_str)
.map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?;
@@ -2598,7 +2617,10 @@ WHERE table_catalog = current_database()",
)
} else {
(
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'".to_string(),
// pg_catalog is readable by everyone, so without the
// privilege check this lists tables of schemas the
// connection's role cannot even enter.
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE, CREATE')".to_string(),
",\n ns.nspname AS schema_name,\n c.relname AS table_name".to_string(),
"\nJOIN pg_catalog.pg_class c ON a.attrelid = c.oid\nJOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid".to_string(),
"ns.nspname, c.relname, a.attnum".to_string(),
@@ -4084,9 +4106,16 @@ mod tests {
}
// -----------------------------------------------------------------------
// CREATE SCHEMA / DROP SCHEMA
// CREATE SCHEMA / DROP SCHEMA / RENAME SCHEMA
// -----------------------------------------------------------------------
#[test]
fn test_expand_rename_schema() {
let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#;
let sql = expand_code(marker, &ScriptLang::Postgresql);
assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";");
}
#[test]
fn test_expand_create_schema() {
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"new_schema"}"#;
@@ -4468,6 +4497,9 @@ mod tests {
assert!(sql.contains("schema_name"));
assert!(sql.contains("table_name"));
assert!(sql.contains("c.relkind = 'r'"));
// Listing every table must not reach into schemas the connection's role
// cannot enter: pg_catalog itself is readable by everyone.
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE, CREATE')"));
}
#[test]
+64
View File
@@ -1081,6 +1081,41 @@ pub struct SqlAnnotations {
pub raw_output: bool,
}
impl SqlAnnotations {
/// If the script declares `-- role <name>`, returns the data table role the
/// query runs as. Only meaningful against a permissioned `datatable://`
/// database; absent means the data table's default role.
///
/// Mirrors `BashAnnotations::ssh_target`: only leading comment lines are
/// scanned, and an exact `-- role <name>` with a valid role name and nothing
/// else on the line is required, so prose like `-- role based access is
/// handled below` never matches.
pub fn datatable_role(code: &str) -> Option<String> {
for line in code.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.starts_with("--") {
break;
}
let mut tokens = line[2..].split_whitespace();
if tokens.next() == Some("role") {
if let Some(role) = tokens.next() {
let is_role_name = !role.is_empty()
&& role
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if is_role_name && tokens.next().is_none() {
return Some(role.to_string());
}
}
}
}
None
}
}
#[annotations("#")]
pub struct BashAnnotations {
pub docker: bool,
@@ -2607,6 +2642,35 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn datatable_role_annotation_is_read_from_the_leading_comment_block() {
assert_eq!(
SqlAnnotations::datatable_role("-- role analyst\nSELECT 1"),
Some("analyst".to_string())
);
// Argument declarations are comments too, so the annotation still parses
// after them.
assert_eq!(
SqlAnnotations::datatable_role("-- $1 name (text)\n-- role read-only_1\nSELECT 1"),
Some("read-only_1".to_string())
);
assert_eq!(SqlAnnotations::datatable_role("SELECT 1"), None);
// Scanning stops at the first non-comment line, so a `-- role` further
// down is prose about the query, not a directive.
assert_eq!(
SqlAnnotations::datatable_role("SELECT 1;\n-- role analyst"),
None
);
for prose in [
"-- role based access is handled below\nSELECT 1",
"-- role\nSELECT 1",
"-- roles analyst\nSELECT 1",
"-- role bad;name\nSELECT 1",
] {
assert_eq!(SqlAnnotations::datatable_role(prose), None, "{prose}");
}
}
fn matcher(id: &str) -> WorkspaceMatcher {
WorkspaceMatcher { id: id.to_string(), include_forks: false }
}
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -134,7 +134,10 @@ pub struct EditResourceType {
/// `Option` conflates: an absent field leaves the extension alone, while an
/// explicit `null` clears it. A hub pull relies on both — a type that stops
/// being a file type has to stop being one locally too.
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option"
)]
pub format_extension: Option<Option<String>>,
}
@@ -1626,7 +1629,6 @@ async fn delete_resources_bulk(
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
// Capture resources for trashbin per path before bulk delete, and
@@ -2129,6 +2131,8 @@ async fn set_resource_value(
}
authorize_azure_devops_reference(authed, db, user_db, w_id, value.as_ref()).await?;
// A data table's roles live in the database its resource points at, so the
// identity behind that path is not free to move while they exist.
let mut tx = user_db.clone().begin(authed).await?;
// `RETURNING resource_type` rather than a second lookup: the advisory below has to know the
+9 -1
View File
@@ -383,7 +383,15 @@ pub async fn resolve_postgres_resource(
) -> Result<Postgres> {
if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") {
// Trigger connections (publication/slot management + logical replication) run
// as the dedicated replication user on custom-instance databases.
// as the dedicated replication user on custom-instance databases — and stream
// every change of the database, so a permissioned one is for its admins.
windmill_common::workspaces::ensure_datatable_admin_access(
db,
w_id,
datatable_name,
&windmill_common::workspaces::DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
let resource_value =
get_datatable_replication_resource_from_db_unchecked(db, w_id, datatable_name).await?;
serde_json::from_value::<Postgres>(resource_value).map_err(|e| Error::SerdeJson {
+10 -2
View File
@@ -65,15 +65,23 @@ pub async fn get_ducklake_from_agent_http(
}
#[allow(dead_code)]
/// An agent worker authenticates as the agent rather than as the job's user, so
/// the job id rides along: the API resolves its owner and authorizes the role
/// against that identity.
pub async fn get_datatable_resource_from_agent_http(
client: &HttpClient,
name: &str,
w_id: &str,
role: Option<&str>,
job_id: &Uuid,
) -> anyhow::Result<serde_json::Value> {
let role_query = role
.map(|r| format!("&role={}", urlencoding::encode(r)))
.unwrap_or_default();
client
.get(&format!(
"/api/w/{}/agent_workers/get_datatable_resource/{}",
w_id, &name
"/api/w/{}/agent_workers/get_datatable_resource/{}?job_id={}{}",
w_id, &name, job_id, role_query
))
.await
}
+81 -25
View File
@@ -13,8 +13,8 @@ use windmill_common::error::{to_anyhow, Error, Result};
use windmill_common::utils::sanitize_string_from_password;
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
use windmill_common::workspaces::{
get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked,
strip_fork_reserved_attach_args, DucklakeCatalogResourceType,
get_datatable_resource_from_db, get_ducklake_from_db_unchecked, parse_datatable_ref,
strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType,
};
use windmill_common::PgDatabase;
use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE;
@@ -1492,13 +1492,9 @@ pub async fn do_duckdb(
.await?
{
probe_blocks.extend(q);
} else if let Some(q) = transform_attach_datatable(
&query_block,
conn,
&mut hidden_passwords,
&job.workspace_id,
)
.await?
} else if let Some(q) =
transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job)
.await?
{
probe_blocks.extend(q);
} else {
@@ -1573,13 +1569,9 @@ pub async fn do_duckdb(
.await?
{
v.extend(ducklake_query);
} else if let Some(datatable_query) = transform_attach_datatable(
&query_block,
conn,
&mut hidden_passwords,
&job.workspace_id,
)
.await?
} else if let Some(datatable_query) =
transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job)
.await?
{
v.extend(datatable_query);
} else {
@@ -2607,26 +2599,65 @@ fn fork_defer_statements(
Ok(stmts)
}
/// What an `ATTACH 'datatable…' AS x` names: the data table, the role it asks
/// for, and the alias the rest of the query uses.
#[derive(Debug, PartialEq)]
struct AttachedDatatable {
name: String,
role: Option<String>,
alias: String,
}
/// Split an ATTACH reference, which is the syntax users type.
fn parse_attach_datatable(query: &str) -> Option<AttachedDatatable> {
lazy_static::lazy_static! {
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?(\?[^':]*)?'\s*AS\s+([^ ;]+)").unwrap();
}
let cap = RE.captures(query)?;
let reference = format!(
"{}{}",
cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main"),
cap.get(2).map(|m| m.as_str()).unwrap_or("")
);
let (name, role) = parse_datatable_ref(&reference);
Some(AttachedDatatable {
name: name.to_string(),
role: role.map(|r| r.to_string()),
alias: cap.get(3).map(|m| m.as_str()).unwrap_or("").to_string(),
})
}
async fn transform_attach_datatable(
query: &str,
conn: &Connection,
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
w_id: &str,
job: &MiniPulledJob,
) -> Result<Option<Vec<String>>> {
lazy_static::lazy_static! {
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap();
}
let Some(cap) = RE.captures(query) else {
let Some(attached) = parse_attach_datatable(query) else {
return Ok(None);
};
let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main");
let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or("");
let name = attached.name.as_str();
let role = attached.role.as_deref();
let alias_name = attached.alias.as_str();
let w_id = job.workspace_id.as_str();
let db_resource = match conn {
Connection::Http(client) => {
get_datatable_resource_from_agent_http(client, name, w_id).await?
get_datatable_resource_from_agent_http(client, name, w_id, role, &job.id).await?
}
Connection::Sql(db) => {
get_datatable_resource_from_db(
db,
w_id,
name,
role,
DatatableAccess::PermissionedAs {
permissioned_as: &job.permissioned_as,
email: &job.permissioned_as_email,
},
)
.await?
}
Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?,
};
if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) {
@@ -2751,6 +2782,31 @@ pub struct Arg {
mod tests {
use super::*;
fn attach_ref(query: &str) -> Option<(String, Option<String>, String)> {
parse_attach_datatable(query).map(|a| (a.name, a.role, a.alias))
}
#[test]
fn attach_datatable_parses_name_and_role() {
assert_eq!(
attach_ref("ATTACH 'datatable://sales?role=analyst' AS dt;"),
Some(("sales".into(), Some("analyst".into()), "dt".into()))
);
// Implicit name.
assert_eq!(
attach_ref("ATTACH 'datatable?role=analyst' AS dt;"),
Some(("main".into(), Some("analyst".into()), "dt".into()))
);
assert_eq!(
attach_ref("ATTACH 'datatable://sales' AS dt;"),
Some(("sales".into(), None, "dt".into()))
);
assert_eq!(
attach_ref("ATTACH 'datatable' AS dt;"),
Some(("main".into(), None, "dt".into()))
);
}
#[test]
fn decode_ffi_error_unescapes_multiline_and_strips_quotes() {
// Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ".
+31 -7
View File
@@ -26,9 +26,11 @@ use windmill_common::azure_workload_identity::WORKLOAD_IDENTITY_PASSWORD;
use windmill_common::error::to_anyhow;
use windmill_common::error::{self, Error};
use windmill_common::worker::{
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
to_raw_value, Connection, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED,
};
use windmill_common::workspaces::{
get_datatable_resource_from_db, parse_datatable_ref, DatatableAccess,
};
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
use windmill_parser::{Arg, Typ};
use windmill_parser_sql::{
@@ -680,15 +682,37 @@ pub async fn do_postgresql(
} else {
match pg_args.get("database").cloned() {
Some(Value::String(db_str)) if db_str.starts_with("datatable://") => {
let db_str = db_str.trim_start_matches("datatable://");
let reference = db_str.trim_start_matches("datatable://");
let (db_str, uri_role) = parse_datatable_ref(reference);
// `-- role <name>` rather than an argument: an argument named
// `role` would collide with a query parameter of that name. It
// wins over a role carried by the reference, which is how
// generated SQL (the database manager's) selects one.
let role = SqlAnnotations::datatable_role(query)
.or_else(|| uri_role.map(|r| r.to_string()));
Some(match conn {
Connection::Http(client) => {
get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id)
.await?
get_datatable_resource_from_agent_http(
client,
&db_str,
&job.workspace_id,
role.as_deref(),
&job.id,
)
.await?
}
Connection::Sql(db) => {
get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str)
.await?
get_datatable_resource_from_db(
db,
&job.workspace_id,
&db_str,
role.as_deref(),
DatatableAccess::PermissionedAs {
permissioned_as: &job.permissioned_as,
email: &job.permissioned_as_email,
},
)
.await?
}
})
}
+22 -8
View File
@@ -1106,6 +1106,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
* @param opts - Optional settings; \`role\` runs the query as that data table
* role (default: the data table's default role)
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.datatable()
@@ -1115,8 +1117,10 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
* SELECT * FROM friends
* WHERE name = \${name} AND age = \${age}::int
* \`.fetch()
* @example
* let sql = wmill.datatable('main', { role: 'operator' })
*/
datatable(name: string = "main"): DatatableSqlTemplateFunction
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
/**
* Create a SQL template function for DuckDB/ducklake queries
@@ -1893,6 +1897,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
* @param opts - Optional settings; \`role\` runs the query as that data table
* role (default: the data table's default role)
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.datatable()
@@ -1902,8 +1908,10 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
* SELECT * FROM friends
* WHERE name = \${name} AND age = \${age}::int
* \`.fetch()
* @example
* let sql = wmill.datatable('main', { role: 'operator' })
*/
datatable(name: string = "main"): DatatableSqlTemplateFunction
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
/**
* Create a SQL template function for DuckDB/ducklake queries
@@ -2774,6 +2782,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
* @param opts - Optional settings; \`role\` runs the query as that data table
* role (default: the data table's default role)
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.datatable()
@@ -2783,8 +2793,10 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
* SELECT * FROM friends
* WHERE name = \${name} AND age = \${age}::int
* \`.fetch()
* @example
* let sql = wmill.datatable('main', { role: 'operator' })
*/
datatable(name: string = "main"): DatatableSqlTemplateFunction
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
/**
* Create a SQL template function for DuckDB/ducklake queries
@@ -4393,10 +4405,12 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
#
# Args:
# name: Database name (default: "main")
# role: DataTable role to run as, on a datatable with permissions
# enabled (default: the data table's default role)
#
# Returns:
# DataTableClient instance
def datatable(name: str = 'main')
def datatable(name: str = 'main', *, role: Optional[str] = None)
# Get a DuckLake client for DuckDB queries.
#
@@ -4587,7 +4601,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill script.
#
@@ -4598,7 +4612,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill flow.
#
@@ -4609,7 +4623,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#
@@ -4670,7 +4684,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_
# ...
#
# results = await parallel(items, process, concurrency=5)
async def parallel(items, fn, concurrency: Optional[int] = None)
async def parallel(items, fn, *, concurrency: Optional[int] = None)
# Commit Kafka offsets for a trigger with auto_commit disabled.
#
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import type { DataTableTables } from '$lib/gen'
import type { DatatableRowAction } from './dbTypes'
import { sortArray } from '$lib/utils'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
@@ -18,11 +20,10 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { type Snippet } from 'svelte'
import type { DbInput } from './dbTypes'
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
import type { SelectedTable } from './DBManager.svelte'
import type { PendingRowAction, SelectedTable } from './DBManager.svelte'
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
import { resource } from 'runed'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
@@ -36,7 +37,16 @@
hasReplResult?: boolean
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
dbSelector?: Snippet<[]>
/** Every data table with its schemas and tables, for the left-pane tree.
* Undefined when the drawer is not on a data table, which is what collapses
* the tree's top level away. */
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
onSelectRole?: (datatable: string, role: string) => void
pendingAction?: PendingRowAction | undefined
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
canManageDatatable?: boolean
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
@@ -59,7 +69,13 @@
hasReplResult = $bindable(false),
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
datatableTree,
datatableTreeLoading,
onSelectDatatable,
onSelectRole,
pendingAction = $bindable(),
onDatatableAction,
canManageDatatable,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
@@ -77,7 +93,10 @@
function getDbSchemasPath(input: DbInput): string {
switch (input.type) {
case 'database':
return input.resourcePath
// The role is part of the identity: two roles on the same data table
// may see different tables, so their schemas cannot share a cache
// entry — and the same string is what selects the role downstream.
return input.resourcePath + (input.role ? `?role=${input.role}` : '')
case 'ducklake':
return 'ducklake://' + input.ducklake
}
@@ -143,7 +162,7 @@
let queryError: string | undefined
const schema = await getDbSchemas(
input.resourceType,
input.resourcePath,
getDbSchemasPath(input),
ws,
(message: string) => (queryError = message),
{ customTag: workerTag }
@@ -305,7 +324,15 @@
: undefined}
{dbType}
refresh={() => refresh()}
{dbSelector}
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
{onSelectRole}
workspace={ws}
currentRole={input.type === 'database' ? input.role : undefined}
bind:pendingAction
{onDatatableAction}
{canManageDatatable}
{onImport}
bind:selectedSchemaKey
bind:selectedTableKey
@@ -1,25 +1,19 @@
<script lang="ts">
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { WorkspaceService, type DataTableTables } from '$lib/gen'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Select from './select/Select.svelte'
import {
ArrowLeft,
Copy,
Download,
Expand,
LoaderCircle,
Minimize,
RefreshCcw,
Upload
} from 'lucide-svelte'
import { ArrowLeft, Copy, Expand, Minimize, RefreshCcw } from 'lucide-svelte'
import DBManagerContent from './DBManagerContent.svelte'
import type { PendingRowAction } from './DBManager.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
import { resource } from 'runed'
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
import type { DatatableRowAction } from './dbTypes'
import ResourcePicker from './ResourcePicker.svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
@@ -41,24 +35,83 @@
// the editor that opened it (set via openDrawer), else the nav workspace.
let ws = $derived(uriState.workspace ?? $workspaceStore)
// Load available datatables when drawer opens with datatable input
const datatables = resource<string[]>([], async () => {
if (!ws) return []
try {
return (await WorkspaceService.listDataTables({ workspace: ws })).map((d) => d.name)
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
// A create started on a data table other than the current one: survives the
// re-mount the switch causes.
let pendingAction = $state<PendingRowAction | undefined>(undefined)
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
// Every data table with its schemas and tables, in one call: this is what the
// left pane's tree navigates, so it has to cover the data tables the user is
// not currently on, not just the selected one.
const datatables = resource(
// The privileges it reports are the connected role's, so the role picked on
// the open data table is part of what is being asked. Gated on the drawer
// being open: this reaches every data table's database in turn, and the
// component is mounted on every logged-in page.
() => [open, ws, uriState.selectedDatatable, uriState.selectedRole] as const,
async ([isOpen, workspace, roleFor, role]): Promise<DataTableTables[]> => {
if (!isOpen || !workspace) return []
try {
return await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
},
{ initialValue: [] }
)
// Roles the *caller* may use, so the picker never offers one that would be
// refused. Absent/disabled permissions yield no roles and hide the picker.
const usableRoles = resource(
() => [ws, uriState.selectedDatatable] as const,
async ([workspace, datatable]) => {
if (!workspace || !datatable) return undefined
try {
return {
datatable,
...(await WorkspaceService.listUsableDatatableRoles({
workspace,
datatableName: datatable
}))
}
} catch (e) {
// Never leave the drawer waiting on this: fall back to the
// unpermissioned shape so it opens and the server picks the role.
console.error('Failed to load datatable roles:', e)
return { datatable, enabled: false, roles: [], default_role: 'admin' }
}
}
)
// A resource keeps its previous value while refetching, and roles are per data
// table: settling from the last one's answer would connect to the new data
// table as a role it may not even have.
const rolesOfCurrent = $derived(
usableRoles.current?.datatable === uriState.selectedDatatable ? usableRoles.current : undefined
)
// The content must not mount until the role is settled: mounting is what fires
// the schema and metadata queries, and a first round sent without a role would
// run — and cache — as whatever the server defaults to.
const roleSettled = $derived(
!uriState.isDatatableInput ||
(rolesOfCurrent !== undefined &&
(!rolesOfCurrent.enabled ||
rolesOfCurrent.roles.length === 0 ||
uriState.selectedRole !== undefined))
)
// Settle the role before anything queries the data table: the schema and
// metadata fetches run as whatever role the input carries, so leaving it unset
// until the user touches the picker would send the first — and cached — round
// of queries as a role they may not be allowed to use.
$effect(() => {
const roles = rolesOfCurrent
if (!roles?.enabled || uriState.selectedRole !== undefined) return
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
if (effective) untrack(() => (uriState.selectedRole = effective))
})
// Refetch datatables when switching to a datatable input
$effect(() => {
if (uriState.isDatatableInput) {
@@ -78,6 +131,10 @@
if (!open) {
expand = false
uriState.closeDrawer()
// An action asked for on one data table must not be waiting when the
// drawer is next opened on another database — or on no data table at
// all, where nothing would recognise it as foreign.
pendingAction = undefined
}
})
@@ -97,6 +154,8 @@
let importDrawerOpen = $state(false)
let importLoading = $state(false)
let importSource = $state<string | undefined>(undefined)
/** Which database an import writes into; set when driven from a tree row. */
let importTarget = $state<string | undefined>(undefined)
let importBehavior = $state<'schema_only' | 'schema_and_data'>('schema_only')
let isPostgresqlInput = $derived(
@@ -116,13 +175,49 @@
return toSourceIdentifier(input.resourcePath)
}
// The tree's row menus act on the data table of the row that was clicked, which
// is not necessarily the one currently open — so the target is set first and the
// headless modals are keyed on it.
let actionDatatable = $state<string | undefined>(undefined)
let migrationsModal = $state<DataTableMigrationsButton | undefined>()
let permissionsDrawer = $state<DataTablePermissionsButton | undefined>()
async function runDatatableAction(datatable: string, action: DatatableRowAction) {
actionDatatable = datatable
// Let the keyed block above mount against the new target before driving it.
await tick()
switch (action) {
case 'migrations':
migrationsModal?.open()
break
case 'roles':
permissionsDrawer?.openPermissions()
break
case 'export':
await handleExportSchema(`datatable://${datatable}`)
break
case 'import':
importTarget = `datatable://${datatable}`
importDrawerOpen = true
break
}
}
function refreshManager() {
dbManagerContent?.refresh()
dbManagerContent?.dbManager()?.dbTable()?.refresh()
refreshRoles()
}
async function handleExportSchema() {
const source = currentSourceIdentifier()
/** Re-read what the tree and the role picker show: both are answers about the
* data table's roles, which the permissions drawer can have just changed. */
function refreshRoles() {
datatables.refetch()
usableRoles.refetch()
}
async function handleExportSchema(explicitSource?: string) {
const source = explicitSource ?? currentSourceIdentifier()
if (!source || !ws) return
try {
exportResult = await WorkspaceService.exportPgSchema({
@@ -137,7 +232,7 @@
async function handleImportDatabase() {
if (!importSource || !ws) return
const target = currentSourceIdentifier()
const target = importTarget ?? currentSourceIdentifier()
if (!target) return
importLoading = true
try {
@@ -183,12 +278,23 @@
noPadding
id="db-manager-drawer"
>
{#if uriState.effectiveInput && ws}
{#key uriState.selectedDatatable}
{#if uriState.effectiveInput && ws && roleSettled}
{#key `${uriState.selectedDatatable}~${uriState.selectedRole ?? ''}`}
<DBManagerContent
bind:this={dbManagerContent}
input={uriState.effectiveInput}
workspace={uriState.workspace}
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
datatableTreeLoading={datatables.loading}
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
onSelectRole={(dt, role) => {
// Setting the data table clears the role, so the order matters.
uriState.selectedDatatable = dt
uriState.selectedRole = role
}}
bind:pendingAction
canManageDatatable={!!($superadmin || $userStore?.is_admin)}
onDatatableAction={runDatatableAction}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey={uriState.selectedSchema}
@@ -196,42 +302,10 @@
onImport={enableImportExport
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
: undefined}
>
{#snippet dbSelector()}
{#if uriState.isDatatableInput}
{#if datatables.loading}
<div class="flex items-center gap-2 text-tertiary ml-2">
<LoaderCircle size={14} class="animate-spin" />
<span class="text-sm">Loading...</span>
</div>
{:else if datatables.current.length >= 1}
<Select
transformInputSelectedText={(s) => `Datatable: ${s}`}
items={datatableItems}
bind:value={uriState.selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
{/if}
{/snippet}
</DBManagerContent>
></DBManagerContent>
{/key}
{/if}
{#snippet actions()}
{#if uriState.isDatatableInput && uriState.selectedDatatable && ws}
<DataTableMigrationsButton
workspace={ws}
datatable={uriState.selectedDatatable}
onSchemaChanged={refreshManager}
/>
{/if}
{#if enableImportExport}
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
<Button startIcon={{ icon: Upload }} onClick={() => (importDrawerOpen = true)}>
Import
</Button>
{/if}
{#if uriState.effectiveInput && ws}
<DbWorkerTagButton
bind:tag={() => workerTag.tag, (v) => (workerTag.tag = v)}
@@ -260,6 +334,25 @@
</DrawerContent>
</Drawer>
{#if actionDatatable && ws}
{#key actionDatatable}
<DataTableMigrationsButton
bind:this={migrationsModal}
hideTrigger
workspace={ws}
datatable={actionDatatable}
onSchemaChanged={refreshManager}
/>
<DataTablePermissionsButton
bind:this={permissionsDrawer}
hideTrigger
workspace={ws}
datatable={actionDatatable}
onSaved={refreshRoles}
/>
{/key}
{/if}
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
{#if exportResult}
@@ -503,7 +503,7 @@
{/if}
{#if askingForConfirmation?.codeContent}
<div
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group"
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group min-w-0"
>
<button
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface-hover"
@@ -512,9 +512,7 @@
>
<ClipboardCopy size={14} />
</button>
<pre class="whitespace-pre-wrap text-sm"
><code>{askingForConfirmation.codeContent}</code></pre
>
<pre class="overflow-x-auto text-sm"><code>{askingForConfirmation.codeContent}</code></pre>
</div>
{/if}
</ConfirmationModal>
@@ -0,0 +1,66 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import SelectDropdown from './select/SelectDropdown.svelte'
import Badge from './common/badge/Badge.svelte'
import { clickOutside } from '$lib/utils'
let {
role,
roles,
onSelect
}: {
/** The role in effect, shown on the badge. */
role: string
/** The roles the caller may switch to. */
roles: string[]
onSelect: (role: string) => void
} = $props()
let open = $state(false)
let anchorEl: HTMLSpanElement | undefined = $state()
const items = $derived(roles.map((r) => ({ label: r, value: r })))
// The table picker's drawer opens at `disposables + 10000`, which the
// dropdown's own z-index would sit under.
const dropdownClass = 'z-[20000]'
</script>
<span
bind:this={anchorEl}
class="relative flex min-w-0"
use:clickOutside={{ onClickOutside: () => (open = false) }}
>
<Badge
clickable
color="gray"
wrapperClass="min-w-0"
class="min-w-0 gap-0.5 pl-2 pr-1 bg-surface-sunken hover:bg-surface-sunken text-primary
transition-[filter,transform] hover:brightness-95 active:brightness-90 active:scale-[0.97]
{open ? 'brightness-95' : ''}"
onclick={(e) => {
// The row underneath folds on click, and picking a role is not that.
e.stopPropagation()
open = !open
}}
>
<!-- A long role name gives way rather than pushing the row's own actions
past its right edge. -->
<span class="truncate">{role}</span>
<ChevronDown
size={11}
class="shrink-0 text-secondary transition-transform {open ? 'rotate-180' : ''}"
/>
</Badge>
<SelectDropdown
processedItems={items}
value={role}
{open}
listAutoWidth={false}
class={dropdownClass}
getInputRect={anchorEl && (() => anchorEl!.getBoundingClientRect())}
onSelectValue={(item) => {
open = false
if (item.value !== role) onSelect(item.value)
}}
/>
</span>
@@ -4,10 +4,22 @@
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
import { withMigrationRole } from './datatableMigrationRole'
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
import { CornerDownLeft } from 'lucide-svelte'
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
let {
workspace,
datatable,
role
}: {
workspace: string
datatable: string
/** The role the surrounding editor/manager is connected as. A migration
* created here declares it, so the DDL runs with the privileges the user
* is actually working under rather than the data table's default. */
role?: string
} = $props()
type Choice = 'run' | 'migrate' | 'cancel'
@@ -69,7 +81,7 @@
function openMigrationModal(statement: string): Promise<boolean> {
return new Promise((resolve) => {
resolveMigrationClosed = (created: boolean) => resolve(created)
newMigrationModal?.open({ codeUp: statement })
newMigrationModal?.open({ codeUp: withMigrationRole(statement, role) })
})
}
@@ -134,6 +146,11 @@
This looks like a schema-changing (DDL) statement. Schema changes are best tracked as
migrations rather than run ad-hoc. Create a migration for it instead?
</p>
{#if role}
<p class="text-sm text-secondary">
It will run as role <span class="font-mono font-semibold">{role}</span>.
</p>
{/if}
<pre
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
>{promptStatement ?? ''}</pre
+6 -1
View File
@@ -216,5 +216,10 @@
</Splitpanes>
{#if datatableName && ws}
<DdlMigrationGuard bind:this={ddlGuard} workspace={ws} datatable={datatableName} />
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={ws}
datatable={datatableName}
role={input.type === 'database' ? input.role : undefined}
/>
{/if}
+7 -8
View File
@@ -9,9 +9,10 @@
kind: FavoriteKind
summary?: string
workspaceId?: string
size?: number
}
let { path, kind, workspaceId, summary }: Props = $props()
let { path, kind, workspaceId, summary, size = 16 }: Props = $props()
let buttonHover = $state(false)
let starred = $derived(favoriteManager.isStarred(path, kind))
@@ -29,17 +30,15 @@
onmouseleave={() => (buttonHover = false)}
class="p-1"
>
<!-- Never filled: a favourite reads as yellow, not as a solid blob, which keeps
the icon the same weight whether or not it is starred. -->
{#if starred}
{#if buttonHover}
<StarOff size={16} fill="currentcolor" />
<StarOff {size} fill="none" class="text-yellow-500" />
{:else}
<Star size={16} fill="currentcolor" />
<Star {size} fill="none" class="text-yellow-500" />
{/if}
{:else}
<Star
class={!buttonHover ? 'opacity-60' : ''}
size={16}
fill={buttonHover ? 'currentcolor' : 'none'}
/>
<Star {size} fill="none" class={buttonHover ? '' : 'opacity-60'} />
{/if}
</button>
@@ -280,9 +280,14 @@ const scriptsV2: typeof legacyScripts = {
...legacyScripts,
postgresql: {
...legacyScripts.postgresql,
// The right join is what keeps a schema with no table in the list. It reads
// `pg_namespace`, which every role can read in full, so without the
// privilege check a schema the connection cannot enter shows up too — as an
// empty one, since `information_schema.columns` does filter by privilege.
code: `
SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')`
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
AND nsp.nspname NOT LIKE 'pg\\_%' AND has_schema_privilege(nsp.oid, 'USAGE, CREATE')`
}
}
@@ -186,7 +186,7 @@
<Icon class={theme[type].classes.icon} />
</div>
{/if}
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
<div class={twMerge('ml-0 text-left flex-1 min-w-0', showIcon ? 'ml-4' : '')}>
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
@@ -665,12 +665,14 @@ export class AIChatManager {
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
appAiChatHelpers = $state<AppAIChatHelpers | undefined>(undefined)
/** Datatable creation policy: enabled flag, datatable name, and optional schema */
/** Datatable creation policy: enabled flag, datatable name, optional schema,
* and the role the app's queries run as. */
datatableCreationPolicy = $state<{
enabled: boolean
datatable: string | undefined
schema: string | undefined
}>({ enabled: false, datatable: undefined, schema: undefined })
role?: string | undefined
}>({ enabled: false, datatable: undefined, schema: undefined, role: undefined })
pendingNewCode = $state<string | undefined>(undefined)
apiTools = $state<Tool<any>[]>([])
aiChatInput = $state<AIChatInput | null>(null)
@@ -6,7 +6,10 @@
const aiChatManager = getAiChatManager()
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
import { workspaceStore } from '$lib/stores'
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
import {
createDatatablesResource,
roleAfterDatatableChange
} from '$lib/components/raw_apps/datatableUtils.svelte'
// Load available datatables from workspace using shared utility
const datatables = createDatatablesResource(() => $workspaceStore)
@@ -37,8 +40,10 @@
}
function handleDefaultChange(datatable: string | undefined, schema: string | undefined) {
aiChatManager.datatableCreationPolicy.datatable = datatable
aiChatManager.datatableCreationPolicy.schema = schema
const policy = aiChatManager.datatableCreationPolicy
policy.role = roleAfterDatatableChange(policy.datatable, datatable, policy.role)
policy.datatable = datatable
policy.schema = schema
}
</script>
@@ -67,6 +72,7 @@
<DefaultDatabaseSelector
datatable={aiChatManager.datatableCreationPolicy.datatable}
schema={aiChatManager.datatableCreationPolicy.schema}
role={aiChatManager.datatableCreationPolicy.role}
onChange={handleDefaultChange}
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
/>
@@ -921,9 +921,14 @@ export function prepareAppSystemMessage(customPrompt?: string): ChatCompletionSy
const policy = aiChatManager.datatableCreationPolicy
const datatableName = policy.datatable ?? 'main'
const schemaPrefix = policy.schema ? `${policy.schema}.` : ''
// Use wmill.datatable() for 'main' (default), otherwise wmill.datatable('name')
const datatableCall =
datatableName === 'main' ? 'wmill.datatable()' : `wmill.datatable('${datatableName}')`
// `wmill.datatable()` for 'main' without a role — the defaults — and the name
// and role spelled out otherwise. A role names the privileges the app's own
// queries run with, so it has to be in the code the model writes.
const datatableCall = policy.role
? `wmill.datatable('${datatableName}', { role: '${policy.role}' })`
: datatableName === 'main'
? 'wmill.datatable()'
: `wmill.datatable('${datatableName}')`
let content = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend.
@@ -0,0 +1,263 @@
<script lang="ts">
import { WorkspaceService, type AclChange, type AclTarget, type DatatableAclInfo } from '$lib/gen'
import { resource } from 'runed'
import { sendUserToast } from '$lib/toast'
import Alert from '../common/alert/Alert.svelte'
import Button from '../common/button/Button.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import Portal from '../Portal.svelte'
import Select from '../select/Select.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import Cell from '../table/Cell.svelte'
import { Trash2 } from 'lucide-svelte'
import PgGrantBuilder from './PgGrantBuilder.svelte'
import { grantScopeLabel, groupGrants, revokeScopeOf } from './aclScopes'
import { ADMIN_DATATABLE_ROLE } from '../dbTypes'
let {
workspace,
datatable,
target,
role,
showOwner = true
}: {
workspace: string
datatable: string
/** Read and act as this role rather than the caller's default one. */
role?: string
/** Off where ownership is not the caller's to move — a data table's whole
* database, which Windmill owns. */
showOwner?: boolean
/** What owner and grants are read and written for. Schemas today; a table
* target is the same call with one more identifier. */
target: AclTarget
} = $props()
const acl = resource(
() => [workspace, datatable, JSON.stringify(target), role] as const,
async ([ws, dt]) =>
await WorkspaceService.getDatatableAcl({
workspace: ws,
datatableName: dt,
kind: target.kind,
schema: target.schema,
table: target.kind === 'table' ? target.table : undefined,
role
})
)
// Nothing is written before its SQL has been shown: creating and especially
// revoking access is not something to discover afterwards.
let pending = $state<
{ change: AclChange; statements: string[]; warnings: string[]; title: string } | undefined
>(undefined)
/** A revoke listed per object takes them all: say where one of them is
* managed on its own. */
const pendingCoversObjects = $derived((pending?.change.objects?.length ?? 0) > 1)
async function confirm(change: AclChange, title: string) {
planning = true
try {
const plan = await WorkspaceService.planDatatableAcl({
workspace,
datatableName: datatable,
requestBody: { target, change, role }
})
pending = { change, statements: plan.statements, warnings: plan.warnings, title }
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
planning = false
}
}
async function apply() {
if (!pending) return
applying = true
try {
await WorkspaceService.applyDatatableAcl({
workspace,
datatableName: datatable,
requestBody: { target, change: pending.change, role }
})
sendUserToast(pending.title)
pending = undefined
await acl.refetch()
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
applying = false
}
}
const info: DatatableAclInfo | undefined = $derived(acl.current)
const roleItems = $derived((info?.roles ?? []).map((r) => ({ value: r, label: r })))
const grantRows = $derived(groupGrants(info?.grants ?? []))
/** Handing it to a role the caller cannot run as: after this they can no
* longer change it back. */
const pendingGivesItAway = $derived(
pending?.change.type === 'set_owner' &&
!!pending.change.role &&
!(info?.usable_roles ?? []).includes(pending.change.role)
)
let planning = $state(false)
let applying = $state(false)
</script>
{#if acl.error}
<Alert type="error" title="Could not read access" size="xs">{String(acl.error)}</Alert>
{:else if !info}
<span class="text-sm text-tertiary">Loading...</span>
{:else}
<div class="flex flex-col gap-6">
{#if showOwner}
<section class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-semibold text-primary">Owner</span>
<span class="text-xs text-secondary">
{target.kind === 'schema'
? 'The role that owns the schema and everything already in it. Changing it also lets the new owner reach what the other roles create here later.'
: 'The role that owns the table. Its owner may always read and write it, and is who ALTER and DROP answer to.'}
</span>
</div>
<Select
items={roleItems}
clearable={false}
disabled={planning || applying}
class="w-64"
bind:value={
() => info.owner,
(role) => {
// The select shows what the database says; a pick is a request,
// and only the applied change moves it.
if (role && role !== info.owner) {
confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`)
}
}
}
/>
{#if !info.roles.includes(info.owner)}
<span class="text-xs text-tertiary">
Currently owned by <span class="font-mono">{info.owner}</span>, which is not one of this
data table's roles.
</span>
{/if}
</section>
{/if}
<section class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-semibold text-primary">Grants</span>
<span class="text-xs text-secondary">
{target.kind === 'database'
? 'What each role may do on the database itself — CREATE is the right to create schemas in it.'
: 'What each role may do, beyond what it owns.'}
</span>
</div>
<PgGrantBuilder
{target}
roles={info.roles}
disabled={planning || applying || !info.can_manage}
supportsMaintain={info.supports_maintain}
dbname={info.dbname}
onAdd={({ role, privileges, scope }) =>
confirm(
{ type: 'grant', role, privileges, scope },
`Granted ${privileges.join(', ')} to ${role}`
)}
/>
{#if grantRows.length === 0}
<span class="text-xs text-tertiary">No grants yet.</span>
{:else}
<DataTable size="xs">
<Head>
<tr>
<Cell head first>Role</Cell>
<Cell head>Privileges</Cell>
<Cell head>On</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each grantRows as grant (grant.grantee + grant.objects
.map((o) => `${o.name}(${o.args ?? ''})`)
.join() + (grant.future ?? ''))}
<Row>
<Cell first>{grant.grantee}</Cell>
<Cell><span class="font-mono text-2xs">{grant.privileges.join(', ')}</span></Cell>
<Cell>{grantScopeLabel(grant)}</Cell>
<Cell last>
{@const revokeScope = revokeScopeOf(grant)}
<!-- Reading a target's access needs no ownership of it, so a row
on one the caller does not own has nothing to offer. And what
`admin` holds is what every role here connects through, so it
is not the drawer's to take away wherever it appears. -->
{#if info.roles.includes(grant.grantee) && revokeScope && info.can_manage && grant.grantee !== ADMIN_DATATABLE_ROLE}
<Button
unifiedSize="xs"
variant="default"
iconOnly
startIcon={{ icon: Trash2 }}
title="Revoke"
disabled={planning || applying}
on:click={() =>
confirm(
{
type: 'revoke',
role: grant.grantee,
privileges: grant.privileges,
scope: revokeScope,
objects: grant.objects
},
`Revoked ${grant.privileges.join(', ')} from ${grant.grantee}`
)}
/>
{/if}
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</section>
</div>
{/if}
<Portal>
<ConfirmationModal
open={!!pending}
title="Confirm running the following"
confirmationText="Run"
type="info"
loading={applying}
onConfirmed={apply}
onCanceled={() => (pending = undefined)}
>
<div class="flex flex-col gap-3 min-w-0">
{#if pendingGivesItAway}
<Alert type="warning" title="You will lose access to this" size="xs">
<span class="font-mono">{pending?.change.role}</span> is not a role you can run as, so once
it owns this you can no longer change its access — only a member of that role can hand it back.
</Alert>
{/if}
{#if pendingCoversObjects}
<Alert type="info" title="This covers every listed object" size="xs">
Permissions on a single table are managed in that table's own permissions drawer.
</Alert>
{/if}
{#each pending?.warnings ?? [] as warning}
<Alert type="warning" title="Warning" size="xs">{warning}</Alert>
{/each}
<span class="text-sm text-secondary">
The following runs against <span class="font-mono">{datatable}</span> in a single transaction:
</span>
<pre class="overflow-auto text-xs bg-surface-secondary p-3 rounded select-all max-h-80"
>{(pending?.statements ?? []).join('\n')}</pre
>
</div>
</ConfirmationModal>
</Portal>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { AclScope } from './aclScopes'
import { privilegesOf, scopeSql, scopesOf } from './aclScopes'
import type { AclTarget } from '$lib/gen'
import Select from '../select/Select.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import Button from '../common/button/Button.svelte'
import { Plus } from 'lucide-svelte'
let {
target,
roles,
supportsMaintain = false,
dbname,
disabled = false,
onAdd
}: {
target: AclTarget
/** Roles the grant can be handed to. */
roles: string[]
/** Postgres 17+, which has one more table privilege to offer. */
supportsMaintain?: boolean
/** Names the database in the statement a database target builds. */
dbname?: string
disabled?: boolean
onAdd: (grant: { role: string; privileges: string[]; scope: AclScope }) => void
} = $props()
let role = $state<string | undefined>(undefined)
let scope = $state<AclScope>('target')
let privileges = $state<string[]>([])
const scopeItems = $derived(
scopesOf(target.kind).map((s) => ({ value: s.value, label: s.label }))
)
const available = $derived(privilegesOf(scope, target.kind, supportsMaintain))
// A privilege only exists for some objects: SELECT means nothing on a
// function, so drop what the new scope cannot carry rather than send it.
$effect(() => {
const allowed = privilegesOf(scope, target.kind, supportsMaintain)
const kept = privileges.filter((p) => allowed.includes(p))
if (kept.length !== privileges.length) privileges = kept
})
const statement = $derived(
privileges.length && role
? `GRANT ${privileges.join(', ')} ON ${scopeSql(scope, target, dbname)} TO ${role}`
: undefined
)
const canAdd = $derived(!!role && privileges.length > 0)
</script>
<div class="flex flex-col gap-2 border rounded-md p-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span class="font-mono text-primary">GRANT</span>
<MultiSelect
bind:value={privileges}
items={available.map((p) => ({ value: p, label: p }))}
placeholder="privileges"
{disabled}
class="min-w-56"
/>
<span class="font-mono text-primary">ON</span>
<Select bind:value={scope} items={scopeItems} clearable={false} {disabled} class="w-52" />
<span class="font-mono text-primary">TO</span>
<Select
bind:value={role}
items={roles.map((r) => ({ value: r, label: r }))}
placeholder="role"
{disabled}
class="w-40"
/>
<Button
unifiedSize="xs"
variant="default"
startIcon={{ icon: Plus }}
disabled={disabled || !canAdd}
on:click={() => {
if (!role) return
onAdd({ role, privileges: [...privileges], scope })
privileges = []
}}
>
Add
</Button>
</div>
{#if statement}
<pre class="text-2xs text-tertiary overflow-x-auto">{statement}</pre>
{/if}
</div>
@@ -0,0 +1,157 @@
import type { AclGrant, AclTarget } from '$lib/gen'
/** Privileges Postgres accepts per kind of object. Mirrors the whitelist the
* backend validates against a privilege missing here just cannot be built. */
/** `CREATE` on a database is the right to create schemas in it. `CONNECT` is
* not offered: every role a data table creates is granted it at creation, so
* building that statement by hand would only ever restate what is already true.
* The backend still accepts it, so a CONNECT grant read off the catalog can be
* revoked. */
export const DATABASE_PRIVILEGES = ['CREATE', 'TEMPORARY']
export const SCHEMA_PRIVILEGES = ['USAGE', 'CREATE']
export const TABLE_PRIVILEGES = [
'SELECT',
'INSERT',
'UPDATE',
'DELETE',
'TRUNCATE',
'REFERENCES',
'TRIGGER'
]
/** Postgres 17 and later only, so it is offered from what the server reports. */
export const MAINTAIN_PRIVILEGE = 'MAINTAIN'
export const SEQUENCE_PRIVILEGES = ['USAGE', 'SELECT', 'UPDATE']
export const FUNCTION_PRIVILEGES = ['EXECUTE']
export type AclScope =
| 'target'
| 'all_tables'
| 'all_sequences'
| 'all_functions'
| 'future_tables'
| 'future_sequences'
| 'future_functions'
export type AclTargetKind = AclTarget['kind']
/** The scopes a target can grant on, in the order the builder offers them. */
export function scopesOf(kind: AclTargetKind): { value: AclScope; label: string }[] {
if (kind === 'database') return [{ value: 'target', label: 'the database itself' }]
if (kind === 'table') return [{ value: 'target', label: 'this table' }]
return [
{ value: 'target', label: 'the schema itself' },
{ value: 'all_tables', label: 'all tables in it' },
{ value: 'all_sequences', label: 'all sequences in it' },
{ value: 'all_functions', label: 'all functions in it' },
{ value: 'future_tables', label: 'tables created later' },
{ value: 'future_sequences', label: 'sequences created later' },
{ value: 'future_functions', label: 'functions created later' }
]
}
export function privilegesOf(
scope: AclScope,
kind: AclTargetKind,
supportsMaintain = false
): string[] {
const tablePrivileges = supportsMaintain
? [...TABLE_PRIVILEGES, MAINTAIN_PRIVILEGE]
: TABLE_PRIVILEGES
switch (scope) {
case 'target':
if (kind === 'database') return DATABASE_PRIVILEGES
return kind === 'schema' ? SCHEMA_PRIVILEGES : tablePrivileges
case 'all_tables':
case 'future_tables':
return tablePrivileges
case 'all_sequences':
case 'future_sequences':
return SEQUENCE_PRIVILEGES
case 'all_functions':
case 'future_functions':
return FUNCTION_PRIVILEGES
}
}
/** What a statement built at this scope reads as, for the builder's own preview. */
export function scopeSql(scope: AclScope, target: AclTarget, dbname?: string): string {
if (target.kind === 'database') return `DATABASE ${dbname ?? ''}`.trim()
const schema = target.schema
switch (scope) {
case 'target':
return target.kind === 'schema' ? `SCHEMA ${schema}` : `TABLE ${schema}.${target.table}`
case 'all_tables':
return `ALL TABLES IN SCHEMA ${schema}`
case 'all_sequences':
return `ALL SEQUENCES IN SCHEMA ${schema}`
case 'all_functions':
return `ALL FUNCTIONS IN SCHEMA ${schema}`
case 'future_tables':
return `TABLES (default privileges in ${schema})`
case 'future_sequences':
return `SEQUENCES (default privileges in ${schema})`
case 'future_functions':
return `FUNCTIONS (default privileges in ${schema})`
}
}
/** One row of the grants table: the same privileges on several objects read as
* one line, since granting them per object is what `ON ALL TABLES` does. */
export type GroupedGrant = {
grantee: string
privileges: string[]
objects: NonNullable<AclGrant['object']>[]
future?: string
}
export function groupGrants(grants: AclGrant[]): GroupedGrant[] {
const rows: GroupedGrant[] = []
for (const grant of grants) {
const existing = grant.object
? rows.find(
(r) =>
r.grantee === grant.grantee &&
r.future === grant.future &&
r.objects[0]?.kind === grant.object?.kind &&
r.privileges.join() === grant.privileges.join()
)
: undefined
if (existing) {
existing.objects.push(grant.object!)
} else {
rows.push({
grantee: grant.grantee,
privileges: grant.privileges,
objects: grant.object ? [grant.object] : [],
future: grant.future
})
}
}
return rows
}
/** The scope a revoke of this row takes, or `undefined` when the builder cannot
* express it Postgres also records default privileges on types, which nothing
* here grants and the API has no scope for. */
export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined {
if (!grant.future) return 'target'
const scope = `future_${grant.future.toLowerCase()}`
return (['future_tables', 'future_sequences', 'future_functions'] as const).find(
(s) => s === scope
)
}
/** How a row reads back: what it covers, in one phrase. */
export function grantScopeLabel(grant: GroupedGrant): string {
if (grant.future) return `${grant.future.toLowerCase()} created later`
if (grant.objects.length === 1) {
const object = grant.objects[0]
// A routine's arguments are part of what it is, so two of the same name
// would otherwise read as one row twice.
const args = object.args !== undefined ? `(${object.args})` : ''
return `${object.kind.toLowerCase()} ${object.name}${args}`
}
if (grant.objects.length > 1)
return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s`
return 'itself'
}
@@ -0,0 +1,60 @@
import { describe, test, expect } from 'vitest'
import { parseMigrationRole, withMigrationRole } from './datatableMigrationRole'
describe('parseMigrationRole', () => {
test('reads an annotation from the leading comment block', () => {
expect(parseMigrationRole('-- role analyst\nBEGIN;\nSELECT 1;\nEND;')).toBe('analyst')
expect(parseMigrationRole('\n\n-- role read-only_1\nBEGIN;')).toBe('read-only_1')
})
test('stops at the first non-comment line', () => {
// Inside the transaction it is prose, not a directive — and the executor
// would not read it either.
expect(parseMigrationRole('BEGIN;\n-- role analyst\nSELECT 1;')).toBeUndefined()
})
test('ignores prose that merely starts with role', () => {
expect(parseMigrationRole('-- role based access is handled below\nBEGIN;')).toBeUndefined()
expect(parseMigrationRole('-- role\nBEGIN;')).toBeUndefined()
expect(parseMigrationRole('-- roles analyst\nBEGIN;')).toBeUndefined()
expect(parseMigrationRole('-- role bad;name\nBEGIN;')).toBeUndefined()
})
test('no annotation means the default role', () => {
expect(parseMigrationRole('BEGIN;\nSELECT 1;\nEND;')).toBeUndefined()
})
})
describe('withMigrationRole', () => {
test('prepends above everything, so the executor still parses it', () => {
const out = withMigrationRole('BEGIN;\nSELECT 1;\nEND;', 'analyst')
expect(out.split('\n')[0]).toBe('-- role analyst')
expect(parseMigrationRole(out)).toBe('analyst')
})
test('replaces rather than stacks annotations', () => {
const once = withMigrationRole('BEGIN;', 'analyst')
const twice = withMigrationRole(once, 'auditor')
expect(twice.match(/-- role /g)?.length).toBe(1)
expect(parseMigrationRole(twice)).toBe('auditor')
})
test('undefined strips the annotation back to the default role', () => {
const stripped = withMigrationRole(withMigrationRole('BEGIN;\nEND;', 'analyst'), undefined)
expect(parseMigrationRole(stripped)).toBeUndefined()
expect(stripped).toBe('BEGIN;\nEND;')
})
test('leaves other leading comments alone', () => {
const out = withMigrationRole('-- adds an index\nBEGIN;', 'analyst')
expect(out).toBe('-- role analyst\n-- adds an index\nBEGIN;')
})
test('round-trips through the select both ways', () => {
let code = 'BEGIN;\nCREATE TABLE t(x int);\nEND;'
for (const role of ['analyst', 'auditor', undefined, 'admin']) {
code = withMigrationRole(code, role)
expect(parseMigrationRole(code)).toBe(role)
}
})
})
@@ -0,0 +1,54 @@
/**
* A migration carries the data table role it runs as in its own SQL, as a
* `-- role <name>` annotation above everything else. There is no separate field:
* the annotation is what the postgres executor reads, and keeping it in the SQL
* is what lets it survive a `wmill sync` round-trip, which only moves .sql files.
*
* These mirror `SqlAnnotations::datatable_role` on the backend annotations are
* only read from the leading comment block, and only an exact `-- role <name>`
* line counts, so prose like `-- role based access is handled below` is not one.
*/
const ROLE_NAME_RE = /^[a-zA-Z0-9_-]+$/
/** The role a migration declares, or undefined when it runs as the default. */
export function parseMigrationRole(sql: string): string | undefined {
for (const raw of sql.split('\n')) {
const line = raw.trim()
if (line === '') continue
if (!line.startsWith('--')) break
const tokens = line.slice(2).trim().split(/\s+/)
if (tokens[0] === 'role' && tokens.length === 2 && ROLE_NAME_RE.test(tokens[1])) {
return tokens[1]
}
}
return undefined
}
/**
* Return `sql` declaring `role` replacing any annotation it already carries,
* or dropping it when `role` is undefined. The annotation has to lead: parsing
* stops at the first non-comment line, and a migration's SQL usually opens with
* `BEGIN`.
*/
export function withMigrationRole(sql: string, role: string | undefined): string {
const lines = sql.split('\n')
// Only the leading comment block can hold the annotation, so only strip there.
let i = 0
const kept: string[] = []
for (; i < lines.length; i++) {
const line = lines[i].trim()
if (line === '') {
kept.push(lines[i])
continue
}
if (!line.startsWith('--')) break
const tokens = line.slice(2).trim().split(/\s+/)
const isRoleLine = tokens[0] === 'role' && tokens.length === 2 && ROLE_NAME_RE.test(tokens[1])
if (!isRoleLine) kept.push(lines[i])
}
const rest = [...kept, ...lines.slice(i)]
// Drop leading blank lines the removal may have left behind.
while (rest.length > 0 && rest[0].trim() === '') rest.shift()
return role ? [`-- role ${role}`, ...rest].join('\n') : rest.join('\n')
}
@@ -5,7 +5,7 @@ import { isDbType } from './dbTypes'
/**
* Single URL param `dbm` encodes the full DB manager state:
* firstSegment~path~schema.table
* firstSegment~path~schema.table~role
*
* firstSegment:
* datatable database with datatable:// resource (resourceType always postgresql)
@@ -26,6 +26,10 @@ import { isDbType } from './dbTypes'
* datatable~main~.customers (schema "public" implied)
* ducklake~main~.orders (schema "main" implied)
* postgresql~$res:u/user/my_pg~public.customers
* datatable~main~~analyst (role only, no schema/table)
*
* role (fourth segment, optional, data tables only):
* the data table role to connect as; omitted means its default role.
*/
const dbManagerSchema = z.object({
@@ -38,6 +42,7 @@ interface ParsedDbm {
resType?: string
schema?: string
table?: string
role?: string
}
function parseDbm(raw: unknown): ParsedDbm | null {
@@ -48,6 +53,7 @@ function parseDbm(raw: unknown): ParsedDbm | null {
const firstSeg = parts[0]
const path = parts[1]
const schemaTable = parts[2] ?? ''
const role = parts[3] || undefined
let type: ParsedDbm['type']
let resType: string | undefined
@@ -81,7 +87,7 @@ function parseDbm(raw: unknown): ParsedDbm | null {
schema = defaultSchemas[type]
}
return { type, path, resType, schema, table }
return { type, path, resType, schema, table, role }
}
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
@@ -97,6 +103,7 @@ function buildDbm(p: ParsedDbm): string {
} else if (schema) {
schemaTable = `${schema}.`
}
if (p.role) return `${firstSeg}~${p.path}~${schemaTable}~${p.role}`
return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}`
}
@@ -105,6 +112,8 @@ export interface DbManagerUriState {
readonly effectiveInput: DbInput | undefined
readonly isDatatableInput: boolean
selectedDatatable: string | undefined
/** Data table role the drawer connects as; undefined means its default. */
selectedRole: string | undefined
selectedSchema: string | undefined
selectedTable: string | undefined
readonly open: boolean
@@ -137,6 +146,7 @@ export function useDbManagerUriState(): DbManagerUriState {
type: 'database' as const,
resourceType: resType as DbType,
resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path,
role: parsed.type === 'datatable' ? parsed.role : undefined,
specificSchema: parsed.schema,
specificTable: parsed.table
}
@@ -194,7 +204,14 @@ export function useDbManagerUriState(): DbManagerUriState {
return parsed?.type === 'datatable' ? parsed.path : undefined
},
set selectedDatatable(v: string | undefined) {
if (v) updateField({ path: v })
// Roles are per data table, so the current one cannot carry over.
if (v) updateField({ path: v, role: undefined })
},
get selectedRole() {
return parsed?.type === 'datatable' ? parsed.role : undefined
},
set selectedRole(v: string | undefined) {
updateField({ role: v })
},
get selectedSchema() {
return parsed?.schema
+19 -3
View File
@@ -9,6 +9,7 @@ import { writingJobOptions } from './jobs/writingJob'
import type { DBSchema, SQLSchema } from '$lib/stores'
import { stringifySchema } from './copilot/lib'
import type { DbInput, DbType } from './dbTypes'
import { withMigrationRole } from './datatableMigrationRole'
import { assert } from '$lib/utils'
import { WorkspaceService } from '$lib/gen'
import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils'
@@ -245,6 +246,7 @@ export type IDbSchemaOps = {
previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise<string>
onCreateSchema: (params: { schema: string }) => Promise<void>
onDeleteSchema: (params: { schema: string }) => Promise<void>
onRenameSchema: (params: { schema: string; newSchema: string }) => Promise<void>
onFetchTableEditorDefinition: (params: {
table: string
schema?: string
@@ -288,6 +290,10 @@ export function dbSchemaOpsWithPreviewScripts({
input.type === 'database' && input.resourcePath.startsWith('datatable://')
? input.resourcePath.slice('datatable://'.length)
: undefined
// The role the manager is connected as. A migration generated here has to
// declare it, or it would run as the data table's default role instead —
// silently doing the DDL with privileges the user did not pick.
const ambientRole = input.type === 'database' ? input.role : undefined
function makeMarker(op: string, payload: Record<string, unknown>): string {
if (ducklake) payload.ducklake = ducklake
@@ -368,12 +374,15 @@ export function dbSchemaOpsWithPreviewScripts({
throw new MigrationRunCancelled()
}
}
const codeUp = wrapMigration(await expandMarker(workspace, language, content))
const codeUp = withMigrationRole(
wrapMigration(await expandMarker(workspace, language, content)),
ambientRole
)
// Down migrations are only generated for Postgres for now.
let codeDown: string | undefined
if (downContent && dbType === 'postgresql') {
const downSql = (await expandMarker(workspace, language, downContent)).trim()
if (downSql) codeDown = wrapMigration(downSql)
if (downSql) codeDown = withMigrationRole(wrapMigration(downSql), ambientRole)
}
const created = await WorkspaceService.createDatatableMigration({
workspace,
@@ -454,6 +463,11 @@ export function dbSchemaOpsWithPreviewScripts({
const downContent = makeMarker('CREATE_SCHEMA', { schema })
await applyDdl(migrationName('drop_schema', schema), content, downContent)
},
onRenameSchema: async ({ schema, newSchema }) => {
const content = makeMarker('RENAME_SCHEMA', { schema, new_schema: newSchema })
const downContent = makeMarker('RENAME_SCHEMA', { schema: newSchema, new_schema: schema })
await applyDdl(migrationName('rename_schema', newSchema), content, downContent)
},
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
let foreignKeys: import('./apps/components/display/dbtable/tableEditor').TableEditorForeignKey[] =
[]
@@ -597,7 +611,9 @@ export function getDefaultDbTag(input: DbInput): string {
export function getDatabaseArg(input: DbInput | undefined) {
if (input?.type === 'database') {
if (input.resourcePath.startsWith('datatable://')) {
return { database: input.resourcePath }
// The role rides in the reference: generated SQL has no natural place
// for the `-- role` annotation a hand-written script would use.
return { database: input.resourcePath + (input.role ? `?role=${input.role}` : '') }
} else {
return { database: '$res:' + input.resourcePath }
}
+10
View File
@@ -3,6 +3,9 @@ export type DbInput =
type: 'database'
resourceType: DbType
resourcePath: string
/** Data table role to connect as. Only meaningful for a `datatable://`
* resourcePath; absent means the data table's default role. */
role?: string
specificSchema?: string
specificTable?: string
}
@@ -23,3 +26,10 @@ export const dbTypes = [
'duckdb'
] as const
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
/** A row-menu action on a data table in the database manager's tree. */
export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import'
/** The role a permissioned data table always has: the connection it resolved to
* before permissions were turned on, so it owns every object created so far. */
export const ADMIN_DATATABLE_ROLE = 'admin'
@@ -4,8 +4,8 @@
import Select from '$lib/components/select/Select.svelte'
import { workspaceStore } from '$lib/stores'
import {
createDatatableAccessResource,
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
@@ -20,6 +20,9 @@
datatable: string | undefined
/** Currently selected schema */
schema: string | undefined
/** The data table role the app's queries run as, if it names one. What a
* role may see is what the schema list has to be read as. */
role?: string | undefined
/** Callback when either value changes */
onChange?: (datatable: string | undefined, schema: string | undefined) => void
/** Description text to show in the popover */
@@ -29,19 +32,28 @@
let {
datatable,
schema,
role = undefined,
onChange,
description = 'Set the default datatable and schema for new tables. This is where AI will create new tables when needed.'
}: Props = $props()
// Load available datatables and schemas using shared utilities
const datatables = createDatatablesResource(() => opWs)
const schemas = createSchemasResource(
const access = createDatatableAccessResource(
() => datatable,
() => role,
() => opWs
)
const datatableItems = $derived(toDatatableItems(datatables.current))
const schemaItems = $derived(toSchemaItems(schemas.current))
// The answer says what it answers for: switching the database above leaves the
// previous one's schemas in hand until the refetch lands, and picking from
// them would name a schema this data table may not have.
const schemaItems = $derived(
access.current.datatable === datatable && access.current.role === role
? toSchemaItems(access.current.schemas)
: []
)
// Track datatable changes to reset schema
let previousDatatable = $state<string | undefined>(undefined)
@@ -4,17 +4,18 @@
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Button from '../common/button/Button.svelte'
import Select from '../select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import type { DataTableRef } from './dataTableRefUtils'
import { untrack } from 'svelte'
import { resource } from 'runed'
import { ArrowLeft, Expand, LoaderCircle, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
import { ArrowLeft, Expand, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
import DBManagerContent from '../DBManagerContent.svelte'
import type { DbInput } from '../dbTypes'
import type { SelectedTable } from '../DBManager.svelte'
import type { PendingRowAction, SelectedTable } from '../DBManager.svelte'
import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
import { useDbManagerTag } from '../dbManagerTag.svelte'
import DbWorkerTagButton from '../DbWorkerTagButton.svelte'
import type { DataTableTables } from '$lib/gen'
const getOpWs = getRawAppOperatingWorkspace()
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
@@ -30,6 +31,8 @@
let open = $state(false)
let selectedDatatable = $state<string | undefined>(undefined)
/** Role the query editor connects as; undefined means the data table's default. */
let selectedRole = $state<string | undefined>(undefined)
// For DB manager
let dbManagerContent: DBManagerContent | undefined = $state()
@@ -40,6 +43,9 @@
// Multi-select mode: selected tables
let selectedTables = $state<SelectedTable[]>([])
// Survives the re-mount a data table switch causes.
let pendingAction = $state<PendingRowAction | undefined>(undefined)
// Selected schema/table from DBManager (for preview)
let selectedSchemaKey = $state<string | undefined>(undefined)
let selectedTableKey = $state<string | undefined>(undefined)
@@ -55,15 +61,85 @@
}
})
export function openDrawer() {
// Auto-select first datatable if only one exists
if (datatables.current.length === 1) {
selectedDatatable = datatables.current[0]
} else if (datatables.current.length > 1 && datatables.current.includes('main')) {
selectedDatatable = 'main'
} else {
selectedDatatable = undefined
// Roles the *caller* may use, so the picker never offers one that would be
// refused. Absent/disabled permissions yield no roles and hide the picker.
const usableRoles = resource(
() => [open, opWs, selectedDatatable] as const,
async ([isOpen, workspace, datatable]) => {
if (!isOpen || !workspace || !datatable) return undefined
try {
return {
datatable,
...(await WorkspaceService.listUsableDatatableRoles({
workspace,
datatableName: datatable
}))
}
} catch (e) {
// Never leave the drawer waiting on this: fall back to the
// unpermissioned shape so it opens and the server picks the role.
console.error('Failed to load datatable roles:', e)
return { datatable, enabled: false, roles: [], default_role: 'admin' }
}
}
)
// Roles are per data table, and a resource keeps its previous value while it
// refetches.
const rolesOfCurrent = $derived(
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
)
// The content must not mount until the role is settled: mounting is what fires
// the schema and metadata queries, and a first round sent without a role would
// run — and cache — as whatever the server defaults to.
const roleSettled = $derived(
selectedDatatable === undefined ||
(rolesOfCurrent !== undefined &&
(!rolesOfCurrent.enabled ||
rolesOfCurrent.roles.length === 0 ||
selectedRole !== undefined))
)
// Settle the role before anything queries the data table: leaving it unset
// until the user touches the picker would send the first — and cached — round
// of queries as a role they may not be allowed to use.
$effect(() => {
const roles = rolesOfCurrent
if (!roles?.enabled || selectedRole !== undefined) return
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
if (effective) untrack(() => (selectedRole = effective))
})
// Every data table with its schemas and tables: the tree is the picker, so it
// has to cover the data tables the query editor is not pointed at. Asked for
// when the drawer opens — it reaches every data table's database in turn, and
// the editor mounts this whether or not anyone opens it.
const datatableTree = resource(
// The privileges it reports are the connected role's, so the role picked on
// the open data table is part of what is being asked — the same key its
// sibling in the DB manager uses.
() => [open, opWs, selectedDatatable, selectedRole] as const,
async ([isOpen, workspace, roleFor, role]): Promise<DataTableTables[]> => {
if (!isOpen || !workspace) return []
try {
return await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
} catch (e) {
console.error('Failed to load datatable tables:', e)
return []
}
},
{ initialValue: [] }
)
export function openDrawer() {
// The tree shows every data table; this only picks which one the query
// editor and the table preview run against.
selectedDatatable = datatables.current.includes('main') ? 'main' : datatables.current[0]
// A role belongs to the data table it was picked on, and this drawer outlives
// the session that picked it: kept, it would query another data table under a
// role of that name, or under one it has never heard of.
selectedRole = undefined
selectedSchemaKey = undefined
selectedTableKey = undefined
selectedTables = []
@@ -71,15 +147,11 @@
open = true
}
let initialTableKey: string | undefined = $state<string | undefined>(undefined)
let initialSchemaKey: string | undefined = $state<string | undefined>(undefined)
export function openDrawerWithRef(ref: DataTableRef) {
selectedDatatable = ref.datatable
selectedRole = undefined
selectedSchemaKey = ref.schema
selectedTableKey = ref.table
initialTableKey = ref.table
initialSchemaKey = ref.schema
selectedTables = []
expand = false
open = true
@@ -88,23 +160,22 @@
export function closeDrawer() {
open = false
dbManagerContent?.clearReplResult()
// Same reason as its sibling: an action outlives the data table it was
// asked for otherwise.
pendingAction = undefined
}
function handleAddTables() {
if (!selectedDatatable) {
sendUserToast('Please select a data table first', true)
return
}
if (selectedTables.length === 0) {
sendUserToast('Please select at least one table', true)
return
}
// Add all selected tables
for (const table of selectedTables) {
const datatable = table.datatable ?? selectedDatatable
if (!datatable) continue
const ref: DataTableRef = {
datatable: selectedDatatable,
datatable,
schema: table.schema,
table: table.table
}
@@ -116,21 +187,17 @@
selectedTables = []
}
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
)
// Carries the picked schema/table, so a click on a row of another data table
// lands on that table once the manager re-mounts against it.
const dbInput: DbInput | undefined = $derived(
selectedDatatable
? {
type: 'database' as const,
resourceType: 'postgresql' as const,
resourcePath: `datatable://${selectedDatatable}`,
specificSchema: initialSchemaKey,
specificTable: initialTableKey
role: selectedRole,
specificSchema: selectedSchemaKey,
specificTable: selectedTableKey
}
: undefined
)
@@ -141,15 +208,13 @@
}
})
// Convert existingRefs to disabledTables format for the current datatable
const disabledTables = $derived(
existingRefs
.filter((ref) => ref.datatable === selectedDatatable && ref.schema && ref.table)
.map((ref) => ({ schema: ref.schema!, table: ref.table! }))
.filter((ref) => ref.schema && ref.table)
.map((ref) => ({ datatable: ref.datatable, schema: ref.schema!, table: ref.table! }))
)
// Can add: has tables selected
const canAdd = $derived(selectedDatatable && selectedTables.length > 0)
const canAdd = $derived(selectedTables.length > 0)
// Shares the drawer-set override with the Database Manager: same data table,
// same worker group needed to reach it.
@@ -175,37 +240,27 @@
noPadding
>
{#if dbInput && opWs}
{#key selectedDatatable}
<DBManagerContent
bind:this={dbManagerContent}
input={dbInput}
workspace={opWs}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey
bind:selectedTableKey
multiSelectMode={true}
bind:selectedTables
{disabledTables}
>
{#snippet dbSelector()}
{#if datatables.loading}
<div class="flex items-center gap-2 text-tertiary ml-2">
<LoaderCircle size={14} class="animate-spin" />
<span class="text-sm">Loading...</span>
</div>
{:else if datatables.current.length >= 1}
<Select
transformInputSelectedText={(s) => `Datatable: ${s}`}
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
{/snippet}
</DBManagerContent>
{/key}
{#if roleSettled}
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
<DBManagerContent
bind:this={dbManagerContent}
input={dbInput}
workspace={opWs}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey
bind:selectedTableKey
multiSelectMode={true}
bind:selectedTables
{disabledTables}
datatableTree={datatableTree.current}
datatableTreeLoading={datatableTree.loading}
onSelectDatatable={(dt) => ((selectedDatatable = dt), (selectedRole = undefined))}
onSelectRole={(dt, role) => ((selectedDatatable = dt), (selectedRole = role))}
bind:pendingAction
/>
{/key}
{/if}
{:else}
<div class="flex items-center justify-center h-full text-tertiary">
<span>Select a data table to explore</span>
@@ -16,6 +16,8 @@
defaultDatatable?: string | undefined
/** Default schema for new tables */
defaultSchema?: string | undefined
/** The data table role the app's queries run as, if it names one. */
defaultRole?: string | undefined
onAdd?: () => void
onRemove?: (index: number) => void
onSelect?: (ref: DataTableRef, index: number) => void
@@ -31,6 +33,7 @@
dataTableRefs = [],
defaultDatatable = undefined,
defaultSchema = undefined,
defaultRole = undefined,
onAdd,
onRemove,
onSelect,
@@ -95,6 +98,7 @@
<DefaultDatabaseSelector
datatable={defaultDatatable}
schema={defaultSchema}
role={defaultRole}
onChange={onDefaultChange}
/>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { roleAfterDatatableChange } from './datatableUtils.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { paneMinPercent } from '$lib/utils/splitpaneSizing'
import RawAppInlineScriptsPanel from './RawAppInlineScriptsPanel.svelte'
@@ -282,6 +283,12 @@
}
}
// The role the app is saved with applies to its own data table; another data
// table named by a tool is read as its default role, like everywhere else.
function appRoleFor(datatableName: string): string | undefined {
return datatableName === data.datatable ? data.role : undefined
}
function isDatatableTableWhitelisted(
datatableName: string,
schemaName: string,
@@ -878,7 +885,8 @@
aiChatManager.datatableCreationPolicy = {
enabled: data.datatable !== undefined,
datatable: data.datatable,
schema: data.schema
schema: data.schema,
role: data.role
}
// Start auto-snapshot
@@ -900,9 +908,14 @@
// Read the current policy from aiChatManager
const policy = aiChatManager.datatableCreationPolicy
// Only update if different to avoid infinite loops
if (data.datatable !== policy.datatable || data.schema !== policy.schema) {
if (
data.datatable !== policy.datatable ||
data.schema !== policy.schema ||
data.role !== policy.role
) {
data.datatable = policy.datatable
data.schema = policy.schema
data.role = policy.role
}
})
@@ -1079,8 +1092,12 @@
return []
}
// The app's data table is read as the role the app runs as, so the AI
// sees the tables that role reaches, not the default role's.
const tables = await WorkspaceService.listDataTableTables({
workspace: opWorkspace
workspace: opWorkspace,
roleFor: data.role ? data.datatable : undefined,
role: data.role
})
return filterDatatableTables(tables)
},
@@ -1106,7 +1123,8 @@
workspace: opWorkspace,
datatableName,
schemaName,
tableName
tableName,
role: appRoleFor(datatableName)
})
return schema.columns
},
@@ -1124,13 +1142,16 @@
}
try {
// Same reference the generated runnables use: the role rides in it, so
// a table the AI creates belongs to the role the app will connect as.
const role = appRoleFor(datatableName)
const result = await runScriptAndPollResult(
{
workspace: opWorkspace,
requestBody: {
language: 'postgresql',
content: sql,
args: { database: `datatable://${datatableName}` }
args: { database: `datatable://${datatableName}${role ? `?role=${role}` : ''}` }
}
},
writingJobOptions
@@ -2358,14 +2379,18 @@
}}
defaultDatatable={data.datatable}
defaultSchema={data.schema}
defaultRole={data.role}
onDefaultChange={(datatable, schema) => {
const role = roleAfterDatatableChange(data.datatable, datatable, data.role)
data.datatable = datatable
data.schema = schema
data.role = role
// Also sync to aiChatManager
aiChatManager.datatableCreationPolicy = {
...aiChatManager.datatableCreationPolicy,
datatable,
schema
schema,
role
}
}}
{runnables}
@@ -39,6 +39,7 @@
defaultDatatable?: string | undefined
/** Default schema for new tables */
defaultSchema?: string | undefined
defaultRole?: string | undefined
onDefaultChange?: (datatable: string | undefined, schema: string | undefined) => void
}
@@ -60,6 +61,7 @@
onDataTableRefsChange,
defaultDatatable = undefined,
defaultSchema = undefined,
defaultRole = undefined,
onDefaultChange
}: Props = $props()
@@ -153,6 +155,7 @@
{dataTableRefs}
{defaultDatatable}
{defaultSchema}
{defaultRole}
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={handleRemoveDataTable}
onSelect={handleSelectDataTable}
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { Sparkles, Plus, List, Ban, ExternalLinkIcon, Loader2 } from 'lucide-svelte'
import type { Policy } from '$lib/gen'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
@@ -21,8 +22,10 @@
import type { Runnable } from './rawAppPolicy'
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
import {
createDatatableAccessResource,
createDatatablesResource,
createSchemasResource,
createRolesResource,
rolesWorthPicking,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
@@ -68,13 +71,119 @@
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
const datatables = createDatatablesResource(() => opWs)
const schemas = createSchemasResource(
// What the picked role can reach, which is not what the data table holds.
const access = createDatatableAccessResource(
() => selectedDatatable,
() => accessRole,
() => opWs
)
const roles = createRolesResource(
() => selectedDatatable,
() => opWs
)
let selectedRole = $state<string | undefined>(undefined)
// A resource answers for the data table (and role) it was asked about, and
// says which. Anything read before its answer matches the selection belongs to
// the previous data table, to another role, or to no question at all — so
// every reader below waits for the stamp rather than trusting `current`.
const loadedRoles = $derived(
roles.current.datatable === selectedDatatable ? roles.current.roles : []
)
const showRolePicker = $derived(rolesWorthPicking(loadedRoles))
const effectiveRole = $derived(
selectedRole !== undefined && loadedRoles.includes(selectedRole) ? selectedRole : undefined
)
// The role this app is created with, written down rather than left to resolve.
// Leaving it out means "whatever the default is then", which silently moves the
// app the day an admin changes it — and that default is not filtered by what
// this caller may use in the first place.
const roleToSave = $derived(effectiveRole)
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
// `undefined` while the list loads, so this is false until it has answered.
const hasNoDatatables = $derived(availableDatatables?.length === 0)
// A workspace with no data tables has nothing to wait for; anything else waits
// for the answer about what is selected.
const rolesSettled = $derived(
hasNoDatatables ||
(selectedDatatable !== undefined && roles.current.datatable === selectedDatatable)
)
// The picked role belongs to the data table it was picked on: two data tables
// can both define an `analyst` that means something different, so a name
// surviving the switch is not the role surviving it.
let rolesPickedOn = $state<string | undefined>(undefined)
$effect(() => {
const loaded = roles.current
if (loaded.datatable !== selectedDatatable) return
const switched = untrack(() => rolesPickedOn) !== selectedDatatable
if (switched || selectedRole === undefined || !loaded.roles.includes(selectedRole)) {
selectedRole = loaded.roles.includes(loaded.defaultRole)
? loaded.defaultRole
: loaded.roles[0]
rolesPickedOn = selectedDatatable
}
})
// Asked as the role the app will run as, picker or no picker: hiding a choice
// there is only one of is not the same as having no role.
const accessRole = $derived(effectiveRole)
// Permissioned, and this caller may run as none of its roles. Leaving the role
// out would save the data table's configured default, which is exactly the one
// they cannot use, so the app would be created with queries that are refused.
const noUsableRole = $derived(
rolesSettled &&
selectedDatatable !== undefined &&
roles.current.permissioned &&
loadedRoles.length === 0
)
// A lookup that failed is not an answer that there is nothing to pick, and an
// app naming this data table would be created against whatever the default
// turns out to be.
const rolesUnknown = $derived(rolesSettled && roles.current.failed)
const accessSettled = $derived(
hasNoDatatables ||
(rolesSettled &&
selectedDatatable !== undefined &&
access.current.datatable === selectedDatatable &&
access.current.role === accessRole)
)
const loadedAccess = $derived(
accessSettled
? access.current
: {
datatable: selectedDatatable,
role: accessRole,
failed: false,
schemas: [],
canCreateSchema: false
}
)
// Nothing was learned about what this role may do here — the request failed, or
// the backend could not reach that database. `canCreateSchema: false` is then
// the absence of an answer, not a refusal to act on.
const accessUnknown = $derived(accessSettled && loadedAccess.failed)
const availableSchemas = $derived(loadedAccess.schemas)
const canCreateSchema = $derived(loadedAccess.canCreateSchema)
// Only the app that will name the data table is refused: with table creation
// off nothing saves it, and an app that does not touch it is not this caller's
// problem to be stopped over.
const blockedByRole = $derived(
(noUsableRole || rolesUnknown || accessUnknown) && tableCreationEnabled
)
// A role that cannot create schemas has nothing to name, so the mode goes back
// to the one every role has — once that is an answer rather than the absence
// of one, since nothing puts `new` back afterwards.
$effect(() => {
if (accessSettled && !accessUnknown && schemaMode === 'new' && !canCreateSchema)
schemaMode = 'none'
})
let hasAutoSelected = false
$effect(() => {
@@ -119,7 +228,6 @@
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
)
const hasNoDatatables = $derived(availableDatatables?.length === 0)
// copilotInfo is a global that stays empty until some ancestor's fetch lands, so
// `enabled` alone cannot tell "no providers" from "not loaded yet" and the modal
@@ -140,7 +248,10 @@
async function start(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (schemaMode === 'new' && newSchemaName && selectedDatatable && opWs) {
// Only for an app that keeps the data table: with creation off nothing names
// it, so making a schema in it is work the app will never refer to — and on
// a database that could not be read, a failure the user cannot act on.
if (tableCreationEnabled && schemaMode === 'new' && newSchemaName && selectedDatatable && opWs) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
@@ -148,7 +259,8 @@
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
resourcePath: `datatable://${selectedDatatable}`,
role: roleToSave
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
@@ -164,9 +276,10 @@
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
schema: effectiveSchema,
role: roleToSave
}
: { tables: formattedTables, datatable: undefined, schema: undefined }
: { tables: formattedTables, datatable: undefined, schema: undefined, role: undefined }
const policy: Policy = {
on_behalf_of: $userStore?.username.includes('@')
@@ -259,15 +372,45 @@
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
<div class="flex flex-row items-center gap-2">
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
{#if noUsableRole || rolesUnknown || accessUnknown}
<!-- With no usable role the access question goes out role-less,
which the server answers as the default role — the one that was
refused — so `accessUnknown` is true here too and says the less
specific thing. -->
<span class="text-xs text-red-600 dark:text-red-400">
{rolesUnknown
? 'could not read its roles'
: noUsableRole
? 'no role you can use'
: 'could not reach it'}
</span>
{/if}
{#if showRolePicker}
<!-- Reads as one phrase — "main as admin" — so the role needs no
label of its own. -->
<span class="text-xs text-secondary">as</span>
<Select
id="datatable-role"
disablePortal
items={loadedRoles.map((r) => ({ value: r, label: r }))}
bind:value={selectedRole}
clearable={false}
placeholder="Role"
size="sm"
class="w-40"
/>
{/if}
</div>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
@@ -276,7 +419,21 @@
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
<ToggleButton
value="new"
label="New"
icon={Plus}
disabled={!canCreateSchema}
tooltip={canCreateSchema
? undefined
: noUsableRole
? `You may run as no role on ${selectedDatatable}`
: accessUnknown
? `Could not read what may be created in ${selectedDatatable}`
: `${effectiveRole ?? 'This role'} cannot create schemas in ${selectedDatatable}`}
{item}
size="sm"
/>
<ToggleButton
value="existing"
label="Existing"
@@ -418,7 +575,11 @@
variant="default"
size="sm"
on:click={() => start(false)}
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
disabled={!templates[selectedTemplateIndex] ||
newSchemaAlreadyExists ||
!rolesSettled ||
!accessSettled ||
blockedByRole}
>
{$copilotInfo.workspaceDisabled ? 'Start' : 'Start without AI'}
</Button>
@@ -426,7 +587,10 @@
<Button
variant="accent"
on:click={() => start(true)}
disabled={!templates[selectedTemplateIndex] ||
disabled={!rolesSettled ||
!accessSettled ||
blockedByRole ||
!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
@@ -16,13 +16,17 @@ export interface RawAppData {
datatable: string | undefined
/** The schema for table creation (if specified) */
schema: string | undefined
/** The data table role the app's queries and table creation run as; absent
* means the data table's default one. */
role?: string
}
/** Default data configuration */
export const DEFAULT_DATA: RawAppData = {
tables: [],
datatable: undefined,
schema: undefined
schema: undefined,
role: undefined
}
/**
@@ -1,9 +1,24 @@
import { resource } from 'runed'
import { workspaceStore, dbSchemas } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
import { ADMIN_DATATABLE_ROLE } from '$lib/components/dbTypes'
import { get } from 'svelte/store'
/**
* The role an app keeps when its default data table changes.
*
* A data table role is defined on one data table, so it does not follow the
* default to another: kept, the app's queries would name a role that data table
* has never heard of, and the one it names here may not be the one it gets.
*/
export function roleAfterDatatableChange(
previous: string | undefined,
next: string | undefined,
role: string | undefined
): string | undefined {
return next === previous ? role : undefined
}
/**
* Creates a resource that loads available datatables from the workspace.
* Pass a getter function that returns the workspace to create a reactive dependency.
@@ -21,42 +36,137 @@ export function createDatatablesResource(getWorkspace: () => string | undefined)
})
}
/**
* Creates a resource that loads schemas for a given datatable.
* The getDatatable getter is used as a reactive dependency - when it changes, schemas are refetched.
* Creates a resource that loads the roles the caller may use on a datatable,
* and the one it defaults to.
*/
export function createSchemasResource(
export function createRolesResource(
getDatatable: () => string | undefined,
getWorkspace: () => string | undefined = () => get(workspaceStore)
) {
return resource<string[]>([() => getDatatable() ?? '', () => getWorkspace() ?? ''], async () => {
const datatable = getDatatable()
const workspace = getWorkspace()
if (!datatable || !workspace) return []
const resourcePath = `datatable://${datatable}`
// Key the schema cache by workspace too: a datatable of the same name can
// exist in both the nav and the acting workspace, so `datatable://<name>`
// alone would let one workspace's schema be reused for the other.
const cacheKey = `${workspace}:${resourcePath}`
const schemas = get(dbSchemas)
let dbSchema = schemas[cacheKey]
if (!dbSchema) {
return resource(
() => [getDatatable() ?? '', getWorkspace() ?? ''] as const,
async ([datatableName, workspace]): Promise<{
/** The data table this answers for: while a switch is in flight the
* previous one's roles are still what `current` holds, and they say
* nothing about the one now selected. */
datatable: string | undefined
/** Whether the data table has permissions on. With none, every caller
* reaches it through its own connection and `roles` is empty because
* there is nothing to pick which is not the same as a permissioned one
* this caller may run as nothing on. */
permissioned: boolean
/** The lookup itself failed, so neither of the above is an answer: an
* empty `roles` here means nothing was learned, not that there is
* nothing to pick. */
failed: boolean
roles: string[]
defaultRole: string
}> => {
const empty = {
datatable: datatableName || undefined,
permissioned: false,
failed: false,
roles: [],
defaultRole: ADMIN_DATATABLE_ROLE
}
if (!datatableName || !workspace) return empty
try {
schemas[cacheKey] = await getDbSchemas('postgresql', resourcePath, workspace, (msg) =>
console.error('Schema error:', msg)
)
dbSchema = get(dbSchemas)[cacheKey]
const res = await WorkspaceService.listUsableDatatableRoles({ workspace, datatableName })
return {
...empty,
permissioned: res.enabled,
roles: res.enabled ? res.roles : [],
defaultRole: res.default_role
}
} catch (e) {
console.error(`Failed to load schema for ${datatable}:`, e)
return []
console.error('Failed to load datatable roles:', e)
return { ...empty, failed: true }
}
},
{
initialValue: {
datatable: undefined,
permissioned: false,
failed: false,
roles: [],
defaultRole: ADMIN_DATATABLE_ROLE
}
}
)
}
if (!dbSchema?.schema) return []
return Object.keys(dbSchema.schema)
})
/**
* Creates a resource that loads, for one data table read as one role, the
* schemas that role can reach and whether it may create more.
*
* Both answers are the connected role's, and one call carries them: asking the
* schema list of a role that cannot see a schema is the same question as asking
* what it may create in.
*/
export function createDatatableAccessResource(
getDatatable: () => string | undefined,
getRole: () => string | undefined,
getWorkspace: () => string | undefined = () => get(workspaceStore)
) {
return resource(
() => [getDatatable() ?? '', getRole() ?? '', getWorkspace() ?? ''] as const,
async ([datatable, role, workspace]): Promise<{
/** What this answers for both halves of it. Until they match the
* selection, the schemas and the right to create one belong to another
* data table or to another role, and at mount they are the initial value
* rather than an answer at all. What a role may create in is exactly the
* question, so an answer computed as a different one settles nothing. */
datatable: string | undefined
role: string | undefined
/** The lookup did not answer the request failed, or the backend kept the
* entry with an error because it could not reach that database. An empty
* `schemas` then means nothing was learned, and `canCreateSchema: false`
* is the absence of an answer rather than a refusal by the role. */
failed: boolean
schemas: string[]
canCreateSchema: boolean
}> => {
const asked = { datatable: datatable || undefined, role: role || undefined, failed: false }
if (!datatable || !workspace) return { ...asked, schemas: [], canCreateSchema: false }
try {
const tables = await WorkspaceService.listDataTableTables({
workspace,
roleFor: datatable,
role: role || undefined
})
const entry = tables.find((t) => t.datatable_name === datatable)
return {
...asked,
failed: entry === undefined || entry.error !== undefined,
schemas: Object.keys(entry?.schemas ?? {}).sort(),
canCreateSchema: !!entry?.can_create_schema
}
} catch (e) {
console.error('Failed to load datatable access:', e)
return { ...asked, failed: true, schemas: [], canCreateSchema: false }
}
},
{
initialValue: {
datatable: undefined,
role: undefined,
failed: false,
schemas: [],
canCreateSchema: false
}
}
)
}
/**
* Whether naming a role says anything here: a data table without permissions has
* none to pick, and one whose single role is the implicit `admin` has no choice
* to offer.
*/
export function rolesWorthPicking(roles: string[]): boolean {
return roles.length > 1 || (roles.length === 1 && roles[0] !== ADMIN_DATATABLE_ROLE)
}
/**
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { roleAfterDatatableChange } from './datatableUtils.svelte'
describe('roleAfterDatatableChange', () => {
it('drops a role that belongs to the data table being left', () => {
// The role exists on `main` and says nothing about `second`, so an app
// whose default moves must not keep naming it.
expect(roleAfterDatatableChange('main', 'second', 'analyst')).toBe(undefined)
expect(roleAfterDatatableChange('main', undefined, 'analyst')).toBe(undefined)
})
it('keeps it when only the schema moved', () => {
expect(roleAfterDatatableChange('main', 'main', 'analyst')).toBe('analyst')
expect(roleAfterDatatableChange(undefined, undefined, undefined)).toBe(undefined)
})
})
@@ -169,6 +169,11 @@
// Open the list modal and the detail view for a specific migration. Used to
// jump to a just-created migration from the "See migration" toast action.
/** Open the migrations list without the trigger button (tree menus drive it). */
export function open() {
openList()
}
export async function openMigration(timestamp: number) {
listOpen = true
await loadMigrations()
@@ -0,0 +1,439 @@
<script lang="ts">
import { Button } from '../common'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import Alert from '../common/alert/Alert.svelte'
import Toggle from '../Toggle.svelte'
import Tooltip from '../Tooltip.svelte'
import TextInput from '../text_input/TextInput.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import CloseButton from '../common/CloseButton.svelte'
import Checkbox from '../common/checkbox/Checkbox.svelte'
import Cell from '../table/Cell.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import { KeyRound, Plus } from 'lucide-svelte'
import {
FolderService,
GroupService,
UserService,
WorkspaceService,
type DatatableRoleInfo
} from '$lib/gen'
import { userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { randomUUID } from '$lib/utils/uuid'
import { ADMIN_DATATABLE_ROLE } from '../dbTypes'
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
import { resource } from 'runed'
import { deepEqual } from 'fast-equals'
let {
workspace,
datatable,
disabled = false,
hideTrigger = false,
onSaved
}: {
workspace: string
datatable: string
disabled?: boolean
/** Mount the drawer without its button, so a caller can drive it via `open()`
* (the database manager opens it from the tree's row menu). */
hideTrigger?: boolean
/** Called once the roles have actually changed, so a caller showing them
* (the manager's role picker) can read them again. */
onSaved?: () => void
} = $props()
// The server refuses to turn permissions on from a fork: its data table points
// either at the database of the workspace it was forked from, which is where
// to set them, or at a copy the fork can drop.
const isFork = $derived(
!!$userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id
)
// Permissions belong to the database, and are managed from the workspace that
// turned them on; from any other workspace reaching the same database they are
// read-only here.
let editable = $state(true)
let ownerWorkspace = $state<string | undefined>(undefined)
// Matches every workspace member, unlike the `all` group whose membership is
// bookkeeping that can drift.
const WILDCARD_TENANT = '*'
// Stable client-side id so a rename (A -> B) is sent as a rename rather than
// read as a delete plus an add, which would drop the role's grants.
type EditedRole = { id: string; name: string; tenants: string[]; pg_rolename?: string }
let open = $state(false)
let loading = $state(false)
let saving = $state(false)
let loadError = $state<string | undefined>(undefined)
let enabled = $state(false)
let roles = $state<EditedRole[]>([])
/** Tracked by id, not name, so renaming the default role keeps it selected. */
let defaultRoleId = $state<string | undefined>(undefined)
/** The last loaded state, to diff renames and detect unsaved changes against. */
let saved = $state<{ enabled: boolean; roles: EditedRole[]; defaultRoleId?: string }>({
enabled: false,
roles: []
})
// Tenants are principals of the workspace that owns the permissions, which
// is this one unless a superadmin manages them from another reaching the
// same database.
const tenantItems = resource([() => ownerWorkspace ?? workspace], async ([ws]) => {
if (!ws) return []
const [users, groups, folders] = await Promise.all([
UserService.listUsernames({ workspace: ws }),
GroupService.listGroupNames({ workspace: ws }),
FolderService.listFolderNames({ workspace: ws })
])
return [
{ value: WILDCARD_TENANT, label: 'Everyone', group: 'Anyone in the workspace' },
...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })),
...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })),
...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' }))
]
})
function toEdited(role: DatatableRoleInfo): EditedRole {
return {
id: randomUUID(),
name: role.name,
tenants: [...(role.tenants ?? [])],
pg_rolename: role.pg_rolename
}
}
async function load() {
loading = true
loadError = undefined
try {
const res = await WorkspaceService.getDatatablePermissions({
workspace,
datatableName: datatable
})
// The backend returns roles in name order; admin leads the list instead,
// since it is the one every other role is defined against.
const loaded = res.roles
.map(toEdited)
.sort(
(a, b) =>
Number(b.name === ADMIN_DATATABLE_ROLE) - Number(a.name === ADMIN_DATATABLE_ROLE)
)
// A data table that has never been opted in comes back with no roles;
// showing admin straight away is what the toggle is about to create.
if (!loaded.some((r) => r.name === ADMIN_DATATABLE_ROLE)) {
loaded.unshift({ id: randomUUID(), name: ADMIN_DATATABLE_ROLE, tenants: [] })
}
enabled = res.enabled
editable = res.editable
ownerWorkspace = res.owner_workspace_id
roles = loaded
defaultRoleId = loaded.find((r) => r.name === res.default_role)?.id ?? loaded[0]?.id
saved = {
enabled: res.enabled,
roles: structuredClone($state.snapshot(loaded)),
defaultRoleId
}
} catch (e) {
loadError = e?.body ?? e?.message ?? String(e)
} finally {
loading = false
}
}
function openDrawer() {
open = true
load()
}
/** Open the drawer without the trigger button (the database manager's tree
* row menu drives it). Not named `open`: that is the drawer's own state. */
export function openPermissions() {
openDrawer()
}
function addRole() {
roles.push({ id: randomUUID(), name: '', tenants: [] })
}
function removeRole(id: string) {
roles = roles.filter((r) => r.id !== id)
if (defaultRoleId === id) {
// Deleting the default falls back to admin rather than leaving the save
// pointing at a role that no longer exists.
defaultRoleId = roles.find((r) => r.name === ADMIN_DATATABLE_ROLE)?.id
}
}
let hasUnsavedChanges = $derived(
!deepEqual(saved, {
enabled,
roles: $state.snapshot(roles) as EditedRole[],
defaultRoleId
})
)
// The backend validates these too; catching them here keeps a half-typed row
// from costing a round trip to find out.
let nameError = $derived.by(() => {
if (!enabled) return undefined
const names = roles.map((r) => r.name.trim())
if (names.some((n) => !n)) return 'Every role needs a name'
const invalid = names.find((n) => !/^[a-zA-Z0-9_-]{1,63}$/.test(n))
if (invalid) return `Invalid role name '${invalid}': letters, digits, '_' or '-' only`
if (new Set(names).size !== names.length) return 'Role names must be unique'
return undefined
})
function buildRequest() {
const savedById = new Map(saved.roles.map((r) => [r.id, r.name]))
return {
enabled,
roles: roles.map((r) => ({ name: r.name.trim(), tenants: $state.snapshot(r.tenants) })),
default_role: roles.find((r) => r.id === defaultRoleId)?.name.trim() ?? ADMIN_DATATABLE_ROLE,
renames: roles
.filter((r) => savedById.has(r.id) && savedById.get(r.id) !== r.name.trim())
.map((r) => ({ from: savedById.get(r.id)!, to: r.name.trim() }))
}
}
let preview = $state<{ statements: string[]; warnings: string[] } | undefined>(undefined)
let applying = $state(false)
// Save is always a two-step: the SQL a change plans out is shown before it is
// run, since creating and especially dropping roles is not undoable.
async function requestPreview() {
saving = true
try {
preview = await WorkspaceService.previewDatatablePermissions({
workspace,
datatableName: datatable,
requestBody: buildRequest()
})
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
saving = false
}
}
async function apply() {
applying = true
try {
await WorkspaceService.setDatatablePermissions({
workspace,
datatableName: datatable,
requestBody: buildRequest()
})
sendUserToast('Data table permissions saved')
preview = undefined
await load()
onSaved?.()
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
applying = false
}
}
</script>
{#if !hideTrigger}
<Button
unifiedSize="xs"
variant="default"
startIcon={{ icon: KeyRound }}
iconOnly
{disabled}
title="Permissions: restrict who can use this data table, and as which database role"
on:click={openDrawer}
/>
{/if}
<Drawer bind:open size="900px">
<DrawerContent
title="Permissions — {datatable}"
on:close={() => (open = false)}
tooltip="Map Windmill roles onto Postgres roles, so a script that runs as a role connects as it and the database enforces what it may touch."
>
{#if loadError}
<Alert type="error" title="Could not load permissions" size="xs">{loadError}</Alert>
{:else if loading}
<span class="text-sm text-tertiary">Loading...</span>
{:else}
<div class="flex flex-col gap-4">
<Toggle
bind:checked={enabled}
disabled={!editable || (isFork && !enabled)}
options={{
right: 'Enable permissions',
rightTooltip:
'While off, every workspace member reaches this data table through its single connection. Turning it off again drops the roles created here, after giving their objects back to admin.'
}}
/>
{#if !editable && ownerWorkspace}
<Alert type="info" title="Managed from workspace {ownerWorkspace}" size="xs">
This data table reaches a database whose permissions were turned on in workspace
{ownerWorkspace}. Its roles and tenants are that workspace's, and its admins change them
there.
</Alert>
{:else if isFork && !enabled}
<Alert type="info" title="Permissions belong to the workspace this was forked from" size="xs">
A fork's data table points either at that workspace's database, which is where to
enable permissions, or at a copy this fork can drop.
</Alert>
{/if}
{#if enabled}
<DataTable>
<Head>
<tr>
<Cell head first>
Role
<Tooltip>
admin is the connection the data table already used, so it owns every existing
object and cannot be renamed or removed. Every other role gets its own Postgres
login, created with no privileges — grant it what it needs from admin.
</Tooltip>
</Cell>
<Cell head>
Tenants
<Tooltip>
Users, groups and folders allowed to run as this role. Workspace admins can use
every role.
</Tooltip>
</Cell>
<Cell head>
Default
<Tooltip>
The role a script gets when it names none — no `-- role` annotation, no `?role=`
in an ATTACH. Callers still have to be one of its tenants.
</Tooltip>
</Cell>
<Cell head last />
</tr>
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#each roles as role (role.id)}
{@const isRoot = role.name === ADMIN_DATATABLE_ROLE}
<Row>
<Cell first class="w-56 align-top">
<div class="flex flex-col gap-1">
<TextInput
bind:value={role.name}
inputProps={{ placeholder: 'Role name', disabled: isRoot }}
/>
{#if role.pg_rolename}
<span class="text-2xs text-tertiary font-mono select-all"
>{role.pg_rolename}</span
>
{/if}
</div>
</Cell>
<Cell class="align-top">
<MultiSelect
items={tenantItems.current ?? []}
bind:value={role.tenants}
groupBy={(item) => item.group}
placeholder="Nobody — Add users, groups or folders"
/>
</Cell>
<Cell class="w-20 align-top">
<div class="flex justify-center pt-2">
<Checkbox
checked={defaultRoleId === role.id}
title="Use this role when a script names none"
onChange={() => (defaultRoleId = role.id)}
/>
</div>
</Cell>
<Cell last class="w-10 align-top">
{#if !isRoot}
<CloseButton small on:close={() => removeRole(role.id)} />
{/if}
</Cell>
</Row>
{/each}
<Row class="!border-0">
<Cell colspan={4} class="pt-0 pb-2">
<div class="flex justify-center">
<Button
unifiedSize="sm"
btnClasses="max-w-fit"
variant="default"
on:click={addRole}
>
<Plus /> New role
</Button>
</div>
</Cell>
</Row>
</tbody>
</DataTable>
{#if nameError}
<span class="text-xs text-red-600">{nameError}</span>
{/if}
{/if}
</div>
{/if}
{#if enabled && !hasUnsavedChanges && !loadError && !loading}
<div class="mt-6 pt-6 border-t">
<PgAclEditor {workspace} {datatable} target={{ kind: 'database' }} showOwner={false} />
</div>
{/if}
{#snippet actions()}
<Button
variant="accent"
unifiedSize="md"
disabled={!editable || !hasUnsavedChanges || loading || !!loadError || !!nameError}
loading={saving}
on:click={requestPreview}
>
Save
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- Portalled to the body: this button is also mounted inside the database
manager's drawer, whose own stacking context would otherwise trap the modal
underneath the permissions drawer sitting next to it. -->
<Portal>
<ConfirmationModal
open={!!preview}
title="Apply permission changes"
confirmationText={preview?.statements.length ? 'Run and save' : 'Save'}
type="info"
loading={applying}
onConfirmed={apply}
onCanceled={() => (preview = undefined)}
>
<div class="flex flex-col gap-3 min-w-0">
{#each preview?.warnings ?? [] as warning}
<Alert type="warning" title="Warning" size="xs">{warning}</Alert>
{/each}
{#if !preview?.statements.length}
<span class="text-sm text-secondary">
No SQL to run — only the tenants of existing roles changed.
</span>
{:else}
<span class="text-sm text-secondary">
The following runs against <span class="font-mono">{datatable}</span> in a single transaction:
</span>
<pre class="overflow-auto text-xs bg-surface-secondary p-3 rounded select-all max-h-80"
>{preview.statements.join('\n')}</pre
>
{/if}
</div>
</ConfirmationModal>
</Portal>
@@ -51,12 +51,11 @@
</script>
<script lang="ts">
import { Plus, PlugZap } from 'lucide-svelte'
import { History, KeyRound, Plus, PlugZap, Trash2 } from 'lucide-svelte'
import DropdownV2 from '../DropdownV2.svelte'
import Button from '../common/button/Button.svelte'
import CloseButton from '../common/CloseButton.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
import Select from '../select/Select.svelte'
@@ -79,7 +78,7 @@
type GetSettingsResponse,
type TestDataTableConnectionResponse
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { resource } from 'runed'
@@ -87,8 +86,9 @@
import { Popover } from '../meltComponents'
import ExploreAssetButton from '../ExploreAssetButton.svelte'
import DataTableMigrationsButton from './DataTableMigrationsButton.svelte'
import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
import { deepEqual } from 'fast-equals'
import { clone } from '$lib/utils'
import { clone, onlyAlphaNumAndUnderscore } from '$lib/utils'
import SettingsFooter from './SettingsFooter.svelte'
import Alert from '../common/alert/Alert.svelte'
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
@@ -253,6 +253,10 @@
}
let confirmationModal = createAsyncConfirmationModal()
// The two components mount their own modal and drawer; the row menu drives them.
let migrationsButtons = $state<Record<string, DataTableMigrationsButton | undefined>>({})
let permissionsButtons = $state<Record<string, DataTablePermissionsButton | undefined>>({})
let dirtyMap = $derived.by(() => {
const map: Record<string, boolean> = {}
for (let i = 0; i < tempSettings.dataTables.length; i++) {
@@ -411,9 +415,16 @@
<Cell class="whitespace-nowrap">
<div class="flex gap-2">
<DataTableMigrationsButton
bind:this={migrationsButtons[dataTable.name]}
hideTrigger
workspace={$workspaceStore ?? ''}
datatable={dataTable.name}
/>
<DataTablePermissionsButton
bind:this={permissionsButtons[dataTable.name]}
hideTrigger
workspace={$workspaceStore ?? ''}
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
<Button
size="xs"
@@ -448,7 +459,36 @@
</div>
</Cell>
<Cell class="w-12">
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
<DropdownV2
items={() => [
{
displayName: 'Migrations',
icon: History,
// Both act on the saved data table, which unsaved edits are not.
disabled: !!dirtyMap[dataTable.name],
tooltip: 'Save the settings first',
action: () => migrationsButtons[dataTable.name]?.open()
},
{
displayName: 'Permissions' + ($enterpriseLicense ? '' : ' (EE)'),
icon: KeyRound,
// The server refuses to plan a permissions change without a
// license, so the entry says so rather than failing later.
disabled: !!dirtyMap[dataTable.name] || !$enterpriseLicense,
tooltip: !$enterpriseLicense
? 'Data table permissions require an Enterprise license'
: 'Save the settings first',
action: () => permissionsButtons[dataTable.name]?.openPermissions()
},
{
displayName: 'Remove',
icon: Trash2,
type: 'delete',
action: () => removeDataTable(dataTableIndex)
}
]}
btnId={'datatable-settings-actions-' + onlyAlphaNumAndUnderscore(dataTable.name)}
/>
</Cell>
</Row>
{/each}

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