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
This commit is contained in:
Diego Imbert
2026-09-04 13:14:51 +02:00
co-authored by Claude Opus 5
parent a274fd85bc
commit e6ca0de9c3
4 changed files with 41 additions and 16 deletions
@@ -1,6 +1,6 @@
{
"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 - 'permissions' ELSE value END\n ), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"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 - 'permissions' ELSE value END\n ), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n WHERE key = ANY($2)\n OR COALESCE((value->'permissions'->>'enabled')::boolean, false) = false\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "6020173a1ed1f946766e7865892ed2010bfd7271fda90166e768be924a854456"
"hash": "7637d311391ab08865b10f69d048583f43c8157abf675358cfd84ff056925fc4"
}
@@ -836,16 +836,27 @@ async fn set_datatable_permissions(
tx.commit().await?;
// What the config no longer names, now that it says so.
// What the config no longer names, now that it says so. A failure here is the
// end of the line for these logins: the config that named them has committed,
// so no later plan diffs against them and nothing will try again. Say which
// ones, since dropping them is now a database administrator's job.
if !deferred.is_empty() {
drop_roles_the_config_no_longer_names(&db, &w_id, &mut client, &deferred)
.await
.map_err(|e| {
Error::ExecutionErr(format!(
"Permissions of data table {datatable_name} were saved, but removing the roles \
they no longer name failed: {e}. Save them again to retry."
))
})?;
if let Err(e) =
drop_roles_the_config_no_longer_names(&db, &w_id, &mut client, &deferred).await
{
let mut orphans: Vec<&str> = deferred
.iter()
.filter_map(|s| s.drops_role.as_deref())
.collect();
orphans.sort();
orphans.dedup();
return Err(Error::ExecutionErr(format!(
"Permissions of data table {datatable_name} were saved, but the Postgres logins \
they no longer name could not be removed: {e}. Saving again will not retry \
them — the config no longer names them. Drop {} by hand.",
orphans.join(", ")
)));
}
}
Ok(format!(
@@ -8121,9 +8121,15 @@ async fn create_workspace_fork(
// A forked data table now points at a fresh database where the parent's roles hold
// nothing, so its cloned `permissions` block is meaningless and is dropped — the fork
// opts in on its own. A data table that was NOT forked still points at the parent's
// database, where those roles do hold grants: keeping its block is what stops a fork
// (which any member may create) from reaching the parent's data as root.
// opts in on its own.
//
// A data table that was NOT forked still points at the parent's database, where those
// roles do hold grants, and neither answer is safe: dropping the block would let a
// fork (which any member may create) reach the parent's data as root, while keeping it
// freezes who may run as what at the moment of the fork — the parent revoking a tenant
// would never reach the copy, and the fork would keep running as the role it named. So
// a permissioned data table is not shared into a fork at all; the fork can fork it, or
// go without it.
let forked_datatable_names: Vec<String> = nw
.forked_datatables
.iter()
@@ -8137,6 +8143,8 @@ async fn create_workspace_fork(
CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value END
), '{}'::jsonb)
FROM jsonb_each(datatable->'datatables')
WHERE key = ANY($2)
OR COALESCE((value->'permissions'->>'enabled')::boolean, false) = false
))
WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'"#,
&forked_id,
@@ -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'
@@ -86,13 +87,18 @@
const showRolePicker = $derived(rolesWorthPicking(availableRoles))
// The picked role belongs to the data table it was picked on, and the one it
// defaults to is what the app gets without saying anything.
// defaults to is what the app gets without saying anything. Two data tables
// can both define an `analyst` that means something different, so the name
// surviving the switch is not the role surviving it.
let rolesPickedOn = $state<string | undefined>(undefined)
$effect(() => {
const available = roles.current.roles
if (selectedRole === undefined || !available.includes(selectedRole)) {
const switched = untrack(() => rolesPickedOn) !== selectedDatatable
if (switched || selectedRole === undefined || !available.includes(selectedRole)) {
selectedRole = available.includes(roles.current.defaultRole)
? roles.current.defaultRole
: available[0]
rolesPickedOn = selectedDatatable
}
})