feat(datatables): a fork shares its parent's data table, restrictions and all

A permissioned data table used to be dropped from a fork's config outright:
copying it would either hand every fork member the connection that owns the
parent's database, or freeze the parent's tenant lists at the moment of the
fork. So a fork could not use one at all.

It now gets a pointer instead — `shared_datatables: {"<name>": {"from":
"<workspace>"}}`, deliberately outside `datatables`, so nothing that plans
role drops or reads which logins a workspace claims can see it. Resolution
follows the pointer and asks every question of the workspace that owns it:
its live config, its live roles, and the caller as *that* workspace knows
them. Checking the fork's own identity would have made forking the way to
reach a data table as admin, since `can_use_datatable_role` starts at
`is_admin` and a fork's owner is an admin of their own fork.

A member of the fork who is not a member of the owner is refused, and does
not see the data table at all. A job permissioned as a folder or a group is
refused too: those exist per workspace, so the fork's admin creates both
sides of the name.

Administering it — permissions, ACLs, migrations, role drops — is refused
from the fork and belongs to the workspace that owns it.

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 22:42:29 +02:00
co-authored by Claude Opus 5
parent c6c26fbd92
commit 497f24bca7
13 changed files with 663 additions and 168 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT ws.datatable AS settings\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "settings",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "1160df7cf1a3ba621949c9a17eafa179d323f60b27e27b7094830350e839f61c"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.datatable->'datatables'->$2 AS own,\n ws.datatable->'shared_datatables'->$2->>'from' AS shared_from\n FROM workspace_settings ws WHERE ws.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "own",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "shared_from",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "27067c8d9a35db71592e154277629a58406dd999eb973a4212ccc3ed5ec325f0"
}
@@ -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"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable_name",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "800e04197578631bd75b2f8e511a9740dcd95424df17059da709b7df2d033db2"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT ws.datatable->'datatables' AS datatables\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatables",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "dc60d43814e2b5220db648677dbb3656e27f1c962c2f0438e0cc06530661fbf8"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = $1::jsonb || jsonb_build_object('shared_datatables', COALESCE((\n SELECT jsonb_object_agg(key, value)\n FROM jsonb_each(CASE WHEN jsonb_typeof(datatable->'shared_datatables') = 'object'\n THEN datatable->'shared_datatables' ELSE '{}'::jsonb END)\n WHERE NOT ($1::jsonb->'datatables' ? key)\n ), '{}'::jsonb))\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "f4c9adda04d44527bff3c8da3696cfcc34b0a89f88964273d1272188a9765f7c"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(\n 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 '{shared_datatables}', (\n SELECT COALESCE(jsonb_object_agg(key, entry), '{}'::jsonb) FROM (\n SELECT key, jsonb_build_object('from', $3::text) AS entry\n FROM jsonb_each(datatable->'datatables')\n WHERE NOT (key = ANY($2))\n AND COALESCE((value->'permissions'->>'enabled')::boolean, false) = true\n UNION ALL\n SELECT key, value AS entry\n FROM jsonb_each(CASE\n WHEN jsonb_typeof(datatable->'shared_datatables') = 'object'\n THEN datatable->'shared_datatables' ELSE '{}'::jsonb END)\n WHERE NOT (key = ANY($2))\n ) shared\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text"
]
},
"nullable": []
},
"hash": "f8e1ebb6e468835a90ef9dc32bead8f3c7829d2f6c4320ec716f3585ee28b0cb"
}
@@ -106,3 +106,90 @@ async fn freeing_a_principal_takes_its_datatable_tenant(db: Pool<Postgres>) -> a
Ok(())
}
/// A fork does not copy a permissioned data table, it points at it — and every
/// question about who may run as what is asked of the workspace it points at.
/// Asking it of the fork instead would make forking a workspace the way to reach
/// its data table as admin: `can_use_datatable_role` starts at `is_admin`, and a
/// fork's owner is an admin of their own fork.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn a_shared_data_table_is_governed_by_the_workspace_that_owns_it(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query(
r#"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = 'test-workspace'"#,
)
.bind(json!({
"datatables": {
"main": {
"database": { "resource_type": "instance", "resource_path": "dt_main" },
"permissions": { "enabled": true, "roles": {
"admin": { "tenants": [] },
"analyst": { "tenants": ["u/test-user-2"] }
}}
}
}
}))
.execute(&db)
.await?;
// A fork of it: no config of its own, one pointer, and both users are admins
// of the fork. test-user-3 is only that — the parent does not know them.
sqlx::query(
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ('wm-fork-t', 'wm-fork-t', 'test-user', 'test-workspace')",
)
.execute(&db)
.await?;
sqlx::query("INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('wm-fork-t', $1)")
.bind(json!({ "datatables": {}, "shared_datatables": { "main": { "from": "test-workspace" } } }))
.execute(&db)
.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', true, 'Admin')",
)
.execute(&db)
.await?;
sqlx::query("DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test3@windmill.dev'")
.execute(&db)
.await?;
let roles = |token: &'static str| async move {
authed(
client().get(format!(
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_usable_roles/main"
)),
token,
)
.send()
.await
};
// An admin of the fork, a plain member of the parent: the role the parent
// gave them, and only that one.
let resp = roles("SECRET_TOKEN_2").await?;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["roles"], json!(["analyst"]));
// A member of the fork alone: the data table is not theirs to see at all.
let resp = roles("SECRET_TOKEN_3").await?;
assert_eq!(resp.status(), 404, "{}", resp.text().await?);
let resp = authed(
client().get(format!(
"http://localhost:{port}/api/w/wm-fork-t/workspaces/list_datatables"
)),
"SECRET_TOKEN_3",
)
.send()
.await?;
assert_eq!(resp.json::<serde_json::Value>().await?, json!([]));
Ok(())
}
@@ -182,15 +182,27 @@ pub(crate) async fn read_datatable_unchecked(
w_id: &str,
datatable_name: &str,
) -> Result<DataTable> {
let value = sqlx::query_scalar!(
"SELECT ws.datatable->'datatables'->$2 FROM workspace_settings ws WHERE ws.workspace_id = $1",
let row = sqlx::query!(
"SELECT ws.datatable->'datatables'->$2 AS own,
ws.datatable->'shared_datatables'->$2->>'from' AS shared_from
FROM workspace_settings ws WHERE ws.workspace_id = $1",
w_id,
datatable_name,
)
.fetch_one(db)
.await?
.filter(|v| !v.is_null())
.ok_or_else(|| Error::NotFound(format!("Data table '{datatable_name}' not found")))?;
.await?;
let value = row
.own
.filter(|v| !v.is_null())
.ok_or_else(|| match row.shared_from {
// Everything reading a data table's own config administers it, and a
// data table is administered where it lives.
Some(owner) => Error::NotAuthorized(format!(
"Data table '{datatable_name}' is shared from workspace '{owner}': \
it can only be administered from there"
)),
None => Error::NotFound(format!("Data table '{datatable_name}' not found")),
})?;
serde_json::from_value(value)
.map_err(|e| Error::internal_err(format!("Invalid data table config: {e}")))
}
@@ -730,22 +742,28 @@ async fn list_usable_datatable_roles(
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<UsableDatatableRoles> {
let datatable = read_datatable_unchecked(&db, &w_id, &datatable_name).await?;
let Some(permissions) = datatable.permissions.filter(|p| p.enabled) else {
// Through the shared-aware lookup: a fork sees its parent's permissioned data
// tables, and the roles it may use on one are the ones it may use *there*.
let authed_ref = authed.to_authed_ref();
let usable = windmill_common::workspaces::usable_datatables(&db, &authed_ref, &w_id)
.await?
.remove(&datatable_name)
.ok_or_else(|| Error::NotFound(format!("Data table '{datatable_name}' not found")))?;
let who = usable.who(&authed_ref);
let Some(permissions) = usable.datatable.permissions.as_ref().filter(|p| p.enabled) else {
return Ok(Json(UsableDatatableRoles {
enabled: false,
roles: vec![],
default_role: ADMIN_DATATABLE_ROLE.to_string(),
}));
};
let authed_ref = authed.to_authed_ref();
Ok(Json(UsableDatatableRoles {
enabled: true,
default_role: permissions.default_role().to_string(),
roles: permissions
.roles
.iter()
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
.filter(|(_, role)| can_use_datatable_role(role, &who))
.map(|(name, _)| name.clone())
.collect(),
}))
@@ -2111,33 +2111,40 @@ struct DataTableListItem {
name: String,
resource_type: String,
resource_path: String,
/// The workspace this data table belongs to, when it is not this one. Its
/// database is not this workspace's to name, so the two fields above are
/// blank for it.
#[serde(skip_serializing_if = "Option::is_none")]
shared_from: Option<String>,
}
async fn list_datatables(
_authed: ApiAuthed,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DataTableListItem>> {
let config = sqlx::query_scalar!(
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let items: Vec<DataTableListItem> = match config {
Some(val) => {
let map: HashMap<String, DataTable> = serde_json::from_value(val).unwrap_or_default();
map.into_iter()
.map(|(name, dt)| DataTableListItem {
let authed_ref = authed.to_authed_ref();
let items = windmill_common::workspaces::usable_datatables(&db, &authed_ref, &w_id)
.await?
.into_iter()
.map(|(name, usable)| {
if usable.owner_w_id == w_id {
DataTableListItem {
name,
resource_type: dt.database.resource_type.as_ref().to_string(),
resource_path: dt.database.resource_path,
})
.collect()
}
None => vec![],
};
resource_type: usable.datatable.database.resource_type.as_ref().to_string(),
resource_path: usable.datatable.database.resource_path,
shared_from: None,
}
} else {
DataTableListItem {
name,
resource_type: String::new(),
resource_path: String::new(),
shared_from: Some(usable.owner_w_id),
}
}
})
.collect();
Ok(Json(items))
}
@@ -2338,7 +2345,7 @@ async fn list_datatable_schemas(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DataTableSchema>> {
let datatable_names = list_datatable_names(&db, &w_id).await?;
let datatable_names = list_datatable_names(&db, &authed, &w_id).await?;
let mut results = Vec::new();
for datatable_name in datatable_names {
@@ -2371,8 +2378,8 @@ async fn list_datatable_tables(
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 datatable_names: Vec<String> = roles.keys().cloned().collect();
let mut results = Vec::new();
for datatable_name in datatable_names {
@@ -2424,26 +2431,20 @@ async fn list_datatable_roles(
authed: &ApiAuthed,
w_id: &str,
) -> Result<HashMap<String, (Vec<String>, String)>> {
let Some(datatables) = sqlx::query_scalar!(
"SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1",
w_id
)
.fetch_optional(db)
.await?
.flatten()
.and_then(|v| serde_json::from_value::<HashMap<String, DataTable>>(v).ok()) else {
return Ok(HashMap::new());
};
let authed_ref = authed.to_authed_ref();
Ok(datatables
let usable = windmill_common::workspaces::usable_datatables(db, &authed_ref, w_id).await?;
Ok(usable
.into_iter()
.map(|(name, dt)| {
let info = match dt.permissions.filter(|p| p.enabled) {
.map(|(name, usable)| {
// A shared data table's tenancy is its owner's question, so it is
// asked of who the caller is there.
let who = usable.who(&authed_ref);
let permissions = usable.datatable.permissions.as_ref().filter(|p| p.enabled);
let info = match permissions {
Some(p) => (
p.roles
.iter()
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
.filter(|(_, role)| can_use_datatable_role(role, &who))
.map(|(role_name, _)| role_name.clone())
.collect(),
p.default_role().to_string(),
@@ -2499,20 +2500,14 @@ async fn get_datatable_resource_as_default_role(
.await
}
async fn list_datatable_names(db: &DB, w_id: &str) -> Result<Vec<String>> {
Ok(sqlx::query_scalar!(
r#"
SELECT jsonb_object_keys(ws.datatable->'datatables') AS datatable_name
FROM workspace_settings ws
WHERE ws.workspace_id = $1
"#,
w_id
/// The data tables the caller can reach here, shared ones included.
async fn list_datatable_names(db: &DB, authed: &ApiAuthed, w_id: &str) -> Result<Vec<String>> {
Ok(
windmill_common::workspaces::usable_datatables(db, &authed.to_authed_ref(), w_id)
.await?
.into_keys()
.collect(),
)
.fetch_all(db)
.await?
.into_iter()
.filter_map(|s| s)
.collect())
}
async fn get_datatable_schema(
@@ -3890,8 +3885,20 @@ async fn edit_datatable_config(
let config: serde_json::Value = serde_json::to_value(new_config.settings)
.map_err(|err| Error::internal_err(err.to_string()))?;
// This form owns `datatables` and nothing else. `shared_datatables` names data
// tables of other workspaces, which this workspace does not administer, so it
// is carried across untouched — except where a name has just been defined
// here: the workspace has taken the name back, and a pointer nothing resolves
// is worse than no pointer.
sqlx::query!(
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
"UPDATE workspace_settings
SET datatable = $1::jsonb || jsonb_build_object('shared_datatables', COALESCE((
SELECT jsonb_object_agg(key, value)
FROM jsonb_each(CASE WHEN jsonb_typeof(datatable->'shared_datatables') = 'object'
THEN datatable->'shared_datatables' ELSE '{}'::jsonb END)
WHERE NOT ($1::jsonb->'datatables' ? key)
), '{}'::jsonb))
WHERE workspace_id = $2",
config,
&w_id
)
@@ -8188,12 +8195,14 @@ async fn create_workspace_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, 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.
// roles do hold grants, so the fork must not carry a copy of the config: dropping the
// `permissions` block would let a fork (which any member may create) reach the parent's
// data as root, and keeping it would freeze who may run as what at the moment of the
// fork. It becomes a `shared_datatables` entry instead — a pointer, resolved in the
// parent, against the caller's identity there.
//
// What the parent itself only points at is carried through naming the same owner, not
// the parent: a chain that grows with every fork is a chain that outlives its middle.
let forked_datatable_names: Vec<String> = nw
.forked_datatables
.iter()
@@ -8201,7 +8210,8 @@ async fn create_workspace_fork(
.collect();
sqlx::query!(
r#"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, '{datatables}', (
SET datatable = jsonb_set(
jsonb_set(datatable, '{datatables}', (
SELECT COALESCE(jsonb_object_agg(
key,
CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value END
@@ -8209,10 +8219,25 @@ async fn create_workspace_fork(
FROM jsonb_each(datatable->'datatables')
WHERE key = ANY($2)
OR COALESCE((value->'permissions'->>'enabled')::boolean, false) = false
))
)),
'{shared_datatables}', (
SELECT COALESCE(jsonb_object_agg(key, entry), '{}'::jsonb) FROM (
SELECT key, jsonb_build_object('from', $3::text) AS entry
FROM jsonb_each(datatable->'datatables')
WHERE NOT (key = ANY($2))
AND COALESCE((value->'permissions'->>'enabled')::boolean, false) = true
UNION ALL
SELECT key, value AS entry
FROM jsonb_each(CASE
WHEN jsonb_typeof(datatable->'shared_datatables') = 'object'
THEN datatable->'shared_datatables' ELSE '{}'::jsonb END)
WHERE NOT (key = ANY($2))
) shared
))
WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'"#,
&forked_id,
&forked_datatable_names[..],
&parent_workspace_id,
)
.execute(&mut *tx)
.await?;
+7 -1
View File
@@ -4821,9 +4821,15 @@ paths:
type: string
resource_type:
type: string
enum: [postgres, instance]
enum: [postgres, instance, ""]
resource_path:
type: string
shared_from:
type: string
description: >-
The workspace this data table belongs to, when it is not
this one. Its database is not this workspace's to name,
so resource_type and resource_path are empty for it.
/w/{workspace}/workspaces/list_datatable_schemas:
get:
+344 -40
View File
@@ -1034,6 +1034,26 @@ pub struct DataTable {
pub permissions: Option<DataTablePermissions>,
}
/// A data table this workspace uses but does not own.
///
/// The entry names the workspace whose data table it is and holds nothing else —
/// no database, no roles, no tenants. A fork gets one of these for each
/// permissioned data table of its parent, so it reads the parent's live config
/// and the parent's live tenancy instead of a copy that would freeze at the
/// moment of the fork. Being outside `datatables` is load-bearing: everything
/// that plans role drops, or reads which logins a workspace claims, walks that
/// map, and none of what is here belongs to this workspace.
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SharedDataTable {
/// The workspace whose data table this points at.
pub from: String,
}
/// The most workspaces a chain of shared data tables may cross. A fork of a fork
/// is ordinary; two workspaces naming each other is not, and would recurse until
/// the stack ran out.
const MAX_SHARED_DATATABLE_HOPS: usize = 8;
/// The role every permissioned data table has: it is the connection the data
/// table resolves to without permissions (`custom_instance_user`, or the
/// postgres resource's own user), so it owns every object created so far and
@@ -1617,6 +1637,9 @@ pub enum DatatableAccess<'a> {
/// every role.
Unchecked,
Authed(crate::db::AuthedRef<'a>),
/// The caller, resolved again in the workspace that owns a shared data table.
/// Owned because it is built during the hop, not borrowed from the request.
AuthedOwned(crate::db::Authed),
/// A job's owner. The identity is only fetched if the data table turns out
/// to be permissioned, so unpermissioned resolutions cost no extra query.
PermissionedAs {
@@ -1756,6 +1779,9 @@ async fn resolve_datatable_role(
DatatableAccess::Unchecked => true,
DatatableAccess::NoIdentity => false,
DatatableAccess::Authed(ref authed) => can_use_datatable_role(role_entry, authed),
DatatableAccess::AuthedOwned(ref authed) => {
can_use_datatable_role(role_entry, &authed.to_authed_ref())
}
DatatableAccess::PermissionedAs { permissioned_as, email } => {
let authed =
crate::auth::fetch_authed_from_permissioned_as(permissioned_as, email, w_id, db)
@@ -1803,6 +1829,234 @@ async fn resolve_datatable_role(
}
}
/// Every data table this workspace can use, with the workspace that owns each.
///
/// Its own come back paired with `w_id`; the ones it only points at come back
/// paired with the workspace they live in, following a chain of forks to its end.
/// Authorizes nothing — who may use which role is still asked of
/// [`can_use_datatable_role`], with the identity [`authed_for_datatable`] builds.
pub async fn effective_datatables(
db: &DB,
w_id: &str,
) -> Result<std::collections::BTreeMap<String, (String, DataTable)>> {
let mut out = std::collections::BTreeMap::new();
let mut pending: Vec<(String, String)> = vec![];
let mut w_id = w_id.to_string();
let mut hops = 0;
loop {
let settings = sqlx::query_scalar!(
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
&w_id,
)
.fetch_optional(db)
.await?
.flatten();
if let Some(own) = settings
.as_ref()
.and_then(|s| s.get("datatables"))
.and_then(|d| d.as_object())
{
for (name, value) in own {
// A name already answered belongs to the workspace nearest the
// caller, which is the one whose pointer led here.
if !out.contains_key(name) {
if let Ok(dt) = serde_json::from_value::<DataTable>(value.clone()) {
out.insert(name.clone(), (w_id.clone(), dt));
}
}
}
}
if hops < MAX_SHARED_DATATABLE_HOPS {
if let Some(shared) = settings
.as_ref()
.and_then(|s| s.get("shared_datatables"))
.and_then(|d| d.as_object())
{
for (name, value) in shared {
if let Ok(entry) = serde_json::from_value::<SharedDataTable>(value.clone()) {
pending.push((name.clone(), entry.from));
}
}
}
}
// Names still unanswered decide where to look next; a workspace already
// walked cannot answer twice, so a cycle runs out of pending entries.
pending.retain(|(name, _)| !out.contains_key(name));
let Some((_, next)) = pending.first().cloned() else {
return Ok(out);
};
pending.retain(|(_, from)| *from != next);
w_id = next;
hops += 1;
}
}
/// A data table this workspace can use, and who the caller is where it lives.
pub struct UsableDataTable {
/// The workspace that owns it — `w_id` itself unless it is shared.
pub owner_w_id: String,
pub datatable: DataTable,
/// The caller in `owner_w_id`, when that is another workspace. Every tenancy
/// question about this data table is asked of this, never of the identity the
/// caller has in the workspace they are browsing.
pub owner_authed: Option<crate::db::Authed>,
}
impl UsableDataTable {
/// The identity to ask [`can_use_datatable_role`] about.
pub fn who<'a>(&'a self, here: &'a crate::db::AuthedRef<'a>) -> crate::db::AuthedRef<'a> {
match &self.owner_authed {
Some(authed) => authed.to_authed_ref(),
None => here.clone(),
}
}
}
/// The data tables this caller can use in `w_id`: every one the workspace owns,
/// plus the ones it only points at and the caller is also a member of. A shared
/// data table the caller cannot reach is left out rather than listed and refused
/// — they are not a member of the workspace it belongs to, so it is not theirs to
/// know about.
pub async fn usable_datatables(
db: &DB,
authed: &crate::db::AuthedRef<'_>,
w_id: &str,
) -> Result<std::collections::BTreeMap<String, UsableDataTable>> {
let mut out = std::collections::BTreeMap::new();
for (name, (owner_w_id, datatable)) in effective_datatables(db, w_id).await? {
let owner_authed = if owner_w_id == w_id {
None
} else {
match authed_for_datatable(db, authed, &owner_w_id, &name).await {
Ok(authed) => Some(authed),
Err(_) => continue,
}
};
out.insert(
name,
UsableDataTable { owner_w_id, datatable, owner_authed },
);
}
Ok(out)
}
/// The caller, resolved in the workspace that owns a data table.
///
/// Every tenancy question about a shared data table has to be asked there — see
/// [`access_in_owner_workspace`], which this shares its rules with.
pub async fn authed_for_datatable(
db: &DB,
authed: &crate::db::AuthedRef<'_>,
owner_w_id: &str,
name: &str,
) -> Result<crate::db::Authed> {
match access_in_owner_workspace(
db,
DatatableAccess::Authed(authed.clone()),
owner_w_id,
owner_w_id,
name,
)
.await?
{
DatatableAccess::AuthedOwned(authed) => Ok(authed),
_ => Err(Error::internal_err(
"resolving a shared data table's caller".to_string(),
)),
}
}
/// Who the caller is in the workspace that owns a shared data table.
///
/// Sharing means the owner's restrictions apply unchanged, and
/// [`can_use_datatable_role`] starts at `is_admin` — so checking the *using*
/// workspace's identity would make forking a workspace the way to reach its data
/// table as admin. The caller is looked up again in the owner workspace by email:
/// a username is per-workspace and can drift, the identity behind it does not.
async fn access_in_owner_workspace<'b>(
db: &DB,
access: DatatableAccess<'_>,
using_w_id: &str,
owner_w_id: &str,
name: &str,
) -> Result<DatatableAccess<'b>> {
let refused = |detail: &str| {
Error::NotAuthorized(format!(
"Data table '{name}' is shared from workspace '{owner_w_id}': {detail}"
))
};
let (permissioned_as, email) = match access {
// An unchecked caller was authorized in its own workspace, which says
// nothing about the owner's. Everything that legitimately runs against
// the owner's database — migrations, ACL edits, permission saves, role
// drops — belongs to the workspace that owns it.
DatatableAccess::Unchecked => {
return Err(refused("it can only be administered from there"))
}
DatatableAccess::NoIdentity => {
return Err(refused("the caller has no identity to resolve there"))
}
DatatableAccess::Authed(ref authed) => {
(format!("u/{}", authed.username), authed.email.to_string())
}
DatatableAccess::AuthedOwned(ref authed) => {
(format!("u/{}", authed.username), authed.email.clone())
}
DatatableAccess::PermissionedAs { permissioned_as, email } => {
(permissioned_as.to_string(), email.to_string())
}
DatatableAccess::Job(job_id) => {
let job = sqlx::query!(
"SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
using_w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("job {job_id} not found in {using_w_id}")))?;
(job.permissioned_as, job.permissioned_as_email)
}
};
// A folder or group exists per workspace, so `f/reports` here and `f/reports`
// there are two different things — and the fork's admin creates both. Only an
// identity that names a person carries across.
if !permissioned_as.starts_with("u/") {
return Err(refused(
"only a workspace member can use it, and this runs as a folder or a group",
));
}
let username = sqlx::query_scalar!(
"SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false",
owner_w_id,
email,
)
.fetch_optional(db)
.await?;
let permissioned_as = match username {
Some(username) => format!("u/{username}"),
None => {
// A superadmin belongs to no workspace and reaches every one; anyone
// else is simply not there, and a `*` tenant would otherwise have let
// them in.
let is_super_admin =
sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email)
.fetch_optional(db)
.await?
.unwrap_or(false);
if !is_super_admin {
return Err(refused("you are not a member of that workspace"));
}
email.clone()
}
};
Ok(DatatableAccess::AuthedOwned(
crate::auth::fetch_authed_from_permissioned_as(&permissioned_as, &email, owner_w_id, db)
.await?,
))
}
async fn get_datatable_resource_inner(
db: &DB,
w_id: &str,
@@ -1811,30 +2065,78 @@ async fn get_datatable_resource_inner(
role: Option<&str>,
access: DatatableAccess<'_>,
) -> Result<serde_json::Value> {
let datatables = sqlx::query_scalar!(
r#"
SELECT ws.datatable->'datatables' AS datatables
get_datatable_resource_hops(db, w_id.to_string(), name, replication, role, access, 0).await
}
fn get_datatable_resource_hops<'a>(
db: &'a DB,
w_id: String,
name: &'a str,
replication: bool,
role: Option<&'a str>,
access: DatatableAccess<'a>,
hops: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send + 'a>> {
Box::pin(async move {
let settings = sqlx::query_scalar!(
r#"
SELECT ws.datatable AS settings
FROM workspace_settings ws
WHERE ws.workspace_id = $1
"#,
&w_id,
)
.fetch_one(db)
.await
.map_err(|err| Error::internal_err(format!("getting datatable {name}: {err}")))?;
&w_id,
)
.fetch_one(db)
.await
.map_err(|err| Error::internal_err(format!("getting datatable {name}: {err}")))?;
let datatable = datatables
.as_ref()
.and_then(|d| d.get(name))
.filter(|v| !v.is_null())
.ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?;
let datatable = serde_json::from_value::<DataTable>(datatable.clone())?;
let datatables = settings.as_ref().and_then(|s| s.get("datatables")).cloned();
let own = datatables
.as_ref()
.and_then(|d| d.get(name))
.filter(|v| !v.is_null());
let internal = matches!(access, DatatableAccess::Unchecked);
let role_override = resolve_datatable_role(db, w_id, name, &datatable, role, access).await?;
// A data table the workspace does not own, only uses: resolve it where it
// lives, as whoever the caller is there.
if own.is_none() {
if let Some(shared) = settings
.as_ref()
.and_then(|s| s.get("shared_datatables"))
.and_then(|d| d.get(name))
.filter(|v| !v.is_null())
{
let shared = serde_json::from_value::<SharedDataTable>(shared.clone())?;
if hops >= MAX_SHARED_DATATABLE_HOPS {
return Err(Error::internal_err(format!(
"Data table '{name}' is shared through more than {MAX_SHARED_DATATABLE_HOPS} workspaces; the chain does not end"
)));
}
let access =
access_in_owner_workspace(db, access, &w_id, &shared.from, name).await?;
return get_datatable_resource_hops(
db,
shared.from,
name,
replication,
role,
access,
hops + 1,
)
.await;
}
}
let mut db_resource =
if datatable.database.resource_type == DataTableCatalogResourceType::Instance {
let datatable = own.ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?;
let datatable = serde_json::from_value::<DataTable>(datatable.clone())?;
let w_id = w_id.as_str();
let internal = matches!(access, DatatableAccess::Unchecked);
let role_override =
resolve_datatable_role(db, w_id, name, &datatable, role, access).await?;
let mut db_resource = if datatable.database.resource_type
== DataTableCatalogResourceType::Instance
{
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
pg_creds.dbname = datatable.database.resource_path.clone();
if replication {
@@ -1861,29 +2163,30 @@ async fn get_datatable_resource_inner(
})?
};
// Before the swap below, not merely outside it: the recorded identity is of the
// connection as the resource resolves it, and swapping a role's login into
// `user` would make every comparison fail.
if resolution_proves_database_identity(internal, &datatable) {
ensure_datatable_database_unchanged(name, &datatable, &db_resource)?;
}
// Before the swap below, not merely outside it: the recorded identity is of the
// connection as the resource resolves it, and swapping a role's login into
// `user` would make every comparison fail.
if resolution_proves_database_identity(internal, &datatable) {
ensure_datatable_database_unchanged(name, &datatable, &db_resource)?;
}
// The role logs in as itself rather than through `SET ROLE`, which a script
// could `RESET ROLE` its way back out of and regain admin's privileges.
if let Some((pg_rolename, pg_password)) = role_override {
let creds = db_resource.as_object_mut().ok_or_else(|| {
Error::internal_err(format!(
"Data table '{name}' does not resolve to a postgres resource"
))
})?;
creds.insert("user".to_string(), serde_json::Value::String(pg_rolename));
creds.insert(
"password".to_string(),
serde_json::Value::String(pg_password),
);
}
// The role logs in as itself rather than through `SET ROLE`, which a script
// could `RESET ROLE` its way back out of and regain admin's privileges.
if let Some((pg_rolename, pg_password)) = role_override {
let creds = db_resource.as_object_mut().ok_or_else(|| {
Error::internal_err(format!(
"Data table '{name}' does not resolve to a postgres resource"
))
})?;
creds.insert("user".to_string(), serde_json::Value::String(pg_rolename));
creds.insert(
"password".to_string(),
serde_json::Value::String(pg_password),
);
}
Ok(db_resource)
Ok(db_resource)
})
}
#[derive(Deserialize, Serialize, Debug)]
@@ -3289,7 +3592,8 @@ mod tests {
// Repointed — however the resource got there, including through a `$var:`
// no guard on the resource itself would see.
assert!(
ensure_datatable_database_unchanged("main", &dt, &resolved("elsewhere", "one")).is_err()
ensure_datatable_database_unchanged("main", &dt, &resolved("elsewhere", "one"))
.is_err()
);
// A config that never recorded one cannot claim to match: the roles it
// names were created against a database nobody wrote down.
@@ -41,11 +41,9 @@
let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined)
// Listed with whether each is permissioned, in one unit: a data table whose
// roles were created in the source's database is not shared with the fork —
// the fork would either run as the data table's own connection, which owns
// everything there, or as a tenant list frozen at fork time. It has to be
// cloned, or the fork goes without it.
// Listed with whether each is permissioned, in one unit: a permissioned data
// table cannot be cloned the copy would carry none of its roles — so the
// fork points at the original and every restriction on it stays the source's.
let allDatatables = resource(
() => effectiveSource,
async (ws) => {
@@ -216,22 +214,18 @@
items={[
{
value: 'keep_original',
// What the backend does is decided by the config, not by this
// label, so where the check did not answer the label says both
// outcomes rather than promising the one it cannot know.
label:
dt.permissioned === undefined
? 'Keep original unless permissioned (check failed)'
: dt.permissioned
? 'Not shared (permissions enabled)'
: 'Keep original'
label: dt.shared_from
? `Keep sharing (from ${dt.shared_from})`
: dt.permissioned
? 'Keep original (shared, same restrictions)'
: 'Keep original'
},
// A clone cannot carry the data table's roles, and the fork's copy
// is stripped of its permissions — so the copy would be readable in
// full by every member of the fork. The backend refuses it; not
// offering it is what keeps the two in step. Where the check could
// not answer, the safe reading is "permissioned".
...(dt.permissioned === false
...(dt.permissioned === false && !dt.shared_from
? [
{ value: 'schema_only', label: 'Clone schema only' },
...(!isCloudHosted() && $userStore?.is_admin