refactor(datatables): role permissions belong to the database, keyed by it and owned by the workspace that turned them on

This commit is contained in:
Diego Imbert
2026-09-07 07:49:29 +02:00
parent f9464c2b8e
commit e1f387914e
38 changed files with 1550 additions and 2696 deletions
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE((datatable->'datatables'->$2->'permissions'->>'enabled')::boolean, false)\n FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "coalesce",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "01b0ebb2d85a8e59b04dd42275d14e04b4a13774d442a8edb6c9cc642140cf45"
}
@@ -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,
false,
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,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,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,30 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\"\n FROM workspace_settings ws, jsonb_each(ws.datatable->'datatables') dt\n WHERE NOT (ws.workspace_id = $1 AND dt.key = $2)\n AND COALESCE((dt.value->'permissions'->>'enabled')::boolean, false)\n AND dt.value->'database' = $3\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"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Jsonb"
]
},
"nullable": [
false,
null
]
},
"hash": "6b6360caca5569643c74c5a4033b136783bc820ed96a9ebe84e4c79a47cc5df4"
}
@@ -1,15 +0,0 @@
{
"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 - 'forked_from' 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": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "77841f6a74d13f92b095a5fdeca59d6b5e01e21890ef53ccb600c2f3506d84a3"
}
@@ -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,
false,
false
]
},
"hash": "82f7f52f60868a3aa659f2bee6b41f357a09e2c94792542287cbbc810451c47a"
}
@@ -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,
false,
false
]
},
"hash": "852c7619569dd676b24319267c3ba1ea98aae07a9c2e30de79b3db411e1ef3e9"
}
@@ -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,
false,
false
]
},
"hash": "95671af1034d05d58fb6e03e555b35534824748db021100a95eb69078d6e0f74"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', '{}'::jsonb)\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "a673eb72797bbdab342b86fe9156dd9c68dc10deedd93cb04a8c6636d736f30b"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM datatable_migrations m\n WHERE m.workspace_id = $1\n AND NOT EXISTS (\n SELECT 1 FROM workspace_settings ws\n WHERE ws.workspace_id = $1 AND ws.datatable->'datatables' ? m.datatable\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "bf7d6e52d23cd0aed238ec1a883baeb468330b51edfb690921c67828c4c0bdf3"
}
@@ -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"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\"\n FROM workspace_settings ws, jsonb_each(ws.datatable->'datatables') dt\n WHERE COALESCE((dt.value->'permissions'->>'enabled')::boolean, false)\n AND dt.value->'permissions'->>'physical_identity' = $1\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"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null
]
},
"hash": "e18ae5aab21cda0837933ab1cb401d1d28e19e3658b333e3248c203eb4895b68"
}
@@ -1,47 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\",\n dt.value->'database' AS \"database!\", r.value AS \"resource?\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id\n CROSS JOIN LATERAL jsonb_each(ws.datatable->'datatables') dt\n LEFT JOIN resource r ON r.workspace_id = ws.workspace_id\n AND dt.value->'database'->>'resource_type' <> 'instance'\n AND r.path = dt.value->'database'->>'resource_path'\n WHERE NOT (ws.workspace_id = $1 AND dt.key = $2)\n AND jsonb_typeof(dt.value->'database') = 'object'\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"
},
{
"ordinal": 3,
"name": "database!",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "resource?",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
null,
false,
null,
true
]
},
"hash": "e53586dfce4275479a353b3bd0ff7100f39a7a7704a8ad31dba82042f6fd0b63"
}
@@ -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"
}
@@ -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 SET permissions = EXCLUDED.permissions, updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "f17b405bb47b4633675b75caffe25d51261c12c016218df01b2897abe94096fe"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', COALESCE((\n SELECT jsonb_object_agg(key, value - 'permissions')\n FROM jsonb_each(datatable->'datatables')\n ), '{}'::jsonb))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f42f40af69449bdd47103c63139318a1d1a61af5ec7032353acd5e89027ae8dd"
}
+1 -1
View File
@@ -1 +1 @@
0c220600e7ceece78a7623fd4185adfb0054d1c9
3579765f3b36a339b9de2a2e8186bcfb681732d9
@@ -0,0 +1 @@
DROP TABLE IF EXISTS datatable_database_permissions;
@@ -0,0 +1,18 @@
-- 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.
CREATE TABLE datatable_database_permissions (
database_key TEXT PRIMARY KEY,
owner_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
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;
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1684,7 +1684,7 @@ async fn delete_user(
// 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_workspace_settings_unchecked`.
// `lock_datatable_permissions_unchecked`.
let memberships = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
&email_to_delete
@@ -2445,7 +2445,7 @@ pub async fn delete_workspace_user_internal(
// 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_workspace_settings_unchecked`.
// 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}"),
@@ -2610,10 +2610,10 @@ async fn delete_workspace_user(
) -> Result<String> {
let mut tx = db.begin().await?;
// Before the `usr` row below — see `lock_workspace_settings_unchecked`. The
// 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_workspace_settings_unchecked(&mut tx, &w_id).await?;
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!(
@@ -30,12 +30,16 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
use windmill_common::workspaces::{
can_use_datatable_role, get_datatable_resource_from_db,
get_datatable_resource_from_db_unchecked, DatatableAccess, ADMIN_DATATABLE_ROLE,
get_datatable_resource_from_db, get_datatable_resource_from_db_unchecked, DatatableAccess,
ADMIN_DATATABLE_ROLE,
};
use windmill_common::{PgDatabase, DB};
use crate::datatable_permissions::{connect_as_admin_unchecked, read_datatable_unchecked};
use crate::datatable_permissions::{connect_as_admin_unchecked, usable_roles};
use windmill_common::workspaces::{
database_permissions_by_key, lock_database_permissions, resolve_datatable_database_unchecked,
DatabasePermissions,
};
pub(crate) fn routes() -> Router {
Router::new()
@@ -254,12 +258,7 @@ async fn connect_as_caller(
// lists are what say who reaches which. Without permissions every member
// resolves to the data table's own connection, which owns everything — so
// there it is the workspace admins' to change, as the roles themselves are.
if !authed.is_admin
&& !read_datatable_unchecked(db, w_id, datatable_name)
.await?
.permissions
.is_some_and(|p| p.enabled)
{
if !authed.is_admin && database_record(db, w_id, datatable_name).await?.is_none() {
return Err(Error::NotAuthorized(format!(
"Only an admin can manage access on data table '{datatable_name}', which has no roles"
)));
@@ -406,6 +405,18 @@ async fn first_unmanageable_object(
Ok(row.map(|row| format!("{schema}.{}", row.get::<_, String>(0))))
}
/// The permissions of the database the data table reaches, when they are on.
async fn database_record(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<Option<DatabasePermissions>> {
let (_, _, key) = resolve_datatable_database_unchecked(db, w_id, datatable_name).await?;
Ok(database_permissions_by_key(db, &key)
.await?
.filter(|r| r.permissions.enabled))
}
/// The data table roles `authed` may themselves run as.
async fn usable_role_names(
db: &DB,
@@ -413,15 +424,16 @@ async fn usable_role_names(
datatable_name: &str,
authed: &ApiAuthed,
) -> Result<Vec<String>> {
let authed_ref = authed.to_authed_ref();
let datatable = read_datatable_unchecked(db, w_id, datatable_name).await?;
Ok(match datatable.permissions.filter(|p| p.enabled) {
Some(p) => p
.roles
.iter()
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
.map(|(name, _)| name.clone())
.collect(),
Ok(match database_record(db, w_id, datatable_name).await? {
Some(record) => {
usable_roles(
db,
w_id,
&record,
&DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?
}
None => vec![ADMIN_DATATABLE_ROLE.to_string()],
})
}
@@ -470,11 +482,10 @@ async fn role_map(
datatable_name: &str,
admin_pg_role: &str,
) -> Result<BTreeMap<String, String>> {
let datatable = read_datatable_unchecked(db, w_id, datatable_name).await?;
let mut map = BTreeMap::new();
map.insert(ADMIN_DATATABLE_ROLE.to_string(), admin_pg_role.to_string());
if let Some(permissions) = datatable.permissions.filter(|p| p.enabled) {
for (name, role) in permissions.roles {
if let Some(record) = database_record(db, w_id, datatable_name).await? {
for (name, role) in record.permissions.roles {
if let Some(pg_rolename) = role.pg_rolename {
map.insert(name, pg_rolename);
}
@@ -1045,7 +1056,9 @@ async fn apply_datatable_acl(
// off the catalog and the config, and a role save running at the same time
// is what changes both under it. Held to the end of this handler.
let mut lock_tx = db.begin().await?;
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut lock_tx, &w_id).await?;
let (_, _, database_key) =
resolve_datatable_database_unchecked(&db, &w_id, &datatable_name).await?;
lock_database_permissions(&mut lock_tx, &database_key).await?;
// Authorization first: the repair below opens a connection as the instance's
// own Postgres user, which is not something a request that is about to be
@@ -33,9 +33,10 @@ use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::utils::require_admin;
use windmill_common::worker::SqlAnnotations;
use windmill_common::workspaces::{
can_use_datatable_role, datatable_database_identity, get_datatable_resource_from_db_unchecked,
physical_database_identity, DataTable, DataTableCatalogResourceType, DataTablePermissions,
ADMIN_DATATABLE_ROLE,
can_use_datatable_role_in_owner_workspace, database_permissions_by_key,
delete_database_permissions, lock_database_permissions, resolve_datatable_database_unchecked,
upsert_database_permissions, DataTable, DataTableCatalogResourceType, DataTablePermissions,
DatabasePermissions, DatatableAccess, ADMIN_DATATABLE_ROLE,
};
use windmill_common::{PgDatabase, DB};
@@ -74,6 +75,13 @@ pub struct DatatablePermissionsInfo {
pub roles: Vec<DatatableRoleInfo>,
/// The role a script gets when it names none.
pub default_role: String,
/// The workspace whose admins manage these permissions and whose principals
/// the tenants are: the one that turned them on. Absent while they are off.
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_workspace_id: Option<String>,
/// Whether the caller may change them from here: an admin of the owning
/// workspace, or a superadmin.
pub editable: bool,
}
#[derive(Deserialize, Debug)]
@@ -175,10 +183,8 @@ pub(crate) fn quote_ident(ident: &str) -> String {
/// Read a data table's config, whatever the caller is.
///
/// Authorization: performs none. What it returns is the config as stored,
/// generated role passwords included, so callers MUST have authorized the read,
/// and MUST NOT pass the value outward without
/// [`windmill_common::workspaces::redact_datatable_settings_for_export`].
/// Authorization: performs none, for any workspace it is handed, so callers
/// MUST have authorized the read.
pub(crate) async fn read_datatable_unchecked(
db: &DB,
w_id: &str,
@@ -231,13 +237,9 @@ pub(crate) async fn ensure_instance_db_can_delegate(db: &DB, w_id: &str, datatab
/// database rather than assumed from its config.
pub(crate) struct AdminConnection {
pub(crate) dbname: String,
/// The database this resolved to, as the config records it so a later
/// resolution can tell it has not moved. `None` for an instance database,
/// which no workspace edit can repoint.
pub(crate) database_identity: Option<String>,
/// The database without the login, instance databases included: what every
/// other data table entry is held apart from.
pub(crate) physical_identity: String,
/// The key of the database this connection reaches, which is what its
/// permissions are stored under.
pub(crate) database_key: String,
pub(crate) admin_pg_role: String,
pub(crate) pg_roles: PgRoleInventory,
/// Whether `PUBLIC` holds CREATE on schema `public`, i.e. every role in this
@@ -349,14 +351,8 @@ pub(crate) async fn connect_as_admin_unchecked(
w_id: &str,
datatable_name: &str,
) -> Result<(tokio_postgres::Client, AdminConnection)> {
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
let database_identity = (read_datatable_unchecked(db, w_id, datatable_name)
.await?
.database
.resource_type
== DataTableCatalogResourceType::Postgresql)
.then(|| datatable_database_identity(&db_resource));
let physical_identity = physical_database_identity(&db_resource);
let (_, db_resource, database_key) =
resolve_datatable_database_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}")))?;
let dbname = pg_db.dbname.clone();
@@ -441,9 +437,9 @@ pub(crate) async fn connect_as_admin_unchecked(
// own, plus the connection they were all created from.
let mut own_pg_roles = vec![admin_pg_role.clone()];
own_pg_roles.extend(
read_datatable_unchecked(db, w_id, datatable_name)
database_permissions_by_key(db, &database_key)
.await?
.permissions
.map(|r| r.permissions)
.filter(|p| p.enabled)
.into_iter()
.flat_map(|p| p.roles.into_values())
@@ -455,8 +451,7 @@ pub(crate) async fn connect_as_admin_unchecked(
client,
AdminConnection {
dbname,
database_identity,
physical_identity,
database_key,
admin_pg_role,
pg_roles: PgRoleInventory { existing, adoptable },
public_schema_is_open,
@@ -465,44 +460,31 @@ pub(crate) async fn connect_as_admin_unchecked(
))
}
/// Plan `req` against the database the data table reaches, whose permissions
/// are `old` (read under the caller's lock, or absent when none are on yet).
async fn build_plan(
db: &DB,
w_id: &str,
datatable_name: &str,
old: Option<&DataTablePermissions>,
req: &SetDatatablePermissions,
) -> Result<(tokio_postgres::Client, RolePlan)> {
) -> Result<(tokio_postgres::Client, AdminConnection, RolePlan)> {
require_datatable_permissions_license().await?;
let datatable = read_datatable_unchecked(db, w_id, datatable_name).await?;
ensure_save_names_what_exists(
db,
w_id,
datatable_name,
datatable.permissions.as_ref(),
req,
)
.await?;
ensure_save_names_what_exists(db, w_id, datatable_name, old, req).await?;
let (client, conn) = connect_as_admin_unchecked(db, w_id, datatable_name).await?;
let plan = crate::datatable_permissions_oss::plan_role_changes(
w_id,
datatable_name,
let mut plan = crate::datatable_permissions_oss::plan_role_changes(
&conn.database_key,
&conn.dbname,
&conn.admin_pg_role,
datatable.permissions.as_ref(),
old,
req,
&conn.pg_roles,
conn.public_schema_is_open,
&conn.default_acl_rules,
)?;
let mut plan = plan;
// Stamped from the connection the roles are about to be created through, so
// a resolution that lands anywhere else later can refuse.
plan.permissions.database_identity = conn.database_identity;
plan.permissions.physical_identity = Some(conn.physical_identity);
if !req.enabled {
// Opting out is never refused; what it strands is said out loud.
let roles: HashSet<&str> = datatable
.permissions
.as_ref()
let roles: HashSet<&str> = old
.map(|p| p.roles.keys().map(String::as_str))
.into_iter()
.flatten()
@@ -517,24 +499,26 @@ async fn build_plan(
));
}
}
Ok((client, plan))
Ok((client, conn, plan))
}
/// The connection and statements that drop a deleted data table's roles, giving
/// its objects back to admin first.
/// The connection and statements that drop every role of the database a data
/// table reaches, giving their objects back to admin first, with the key of that
/// database.
///
/// Resolved separately from being run: resolving needs the data table's config,
/// which the save is about to remove, while running is irreversible and must not
/// happen until that save has committed.
pub(crate) type PlannedRoleDrop = (tokio_postgres::Client, RolePlan);
/// which a deletion may be about to remove, while running is irreversible and
/// must not happen until that deletion has committed.
pub(crate) type PlannedRoleDrop = (tokio_postgres::Client, RolePlan, String);
/// Plan the removal of every Postgres role of a data table that is being
/// deleted, against the config as it still stands.
/// Plan the removal of every Postgres role of the database a data table reaches,
/// for a database that is going away with the data table — a workspace being
/// deleted, a fork's clone being dropped.
///
/// A data table whose database is already unreachable must still be removable
/// from the config, so a failure here is logged and the deletion goes ahead
/// without a plan — leaving roles that only a `DROP ROLE` by hand will clear.
pub(crate) async fn plan_drop_of_deleted_datatable(
/// A database that is already unreachable must not block the deletion, so a
/// failure here is logged and the deletion goes ahead without a plan — leaving
/// roles that only a `DROP ROLE` by hand will clear.
pub(crate) async fn plan_drop_of_datatable_roles(
db: &DB,
w_id: &str,
datatable_name: &str,
@@ -546,66 +530,54 @@ pub(crate) async fn plan_drop_of_deleted_datatable(
renames: vec![],
};
let res = async {
let datatable = read_datatable_unchecked(db, w_id, datatable_name).await?;
if !datatable
.permissions
.as_ref()
.is_some_and(|p| p.enabled && p.roles.len() > 1)
{
return Ok(None);
}
build_plan(db, w_id, datatable_name, &req).await.map(Some)
let (_, _, key) = resolve_datatable_database_unchecked(db, w_id, datatable_name).await?;
let Some(record) = database_permissions_by_key(db, &key)
.await?
.filter(|r| r.permissions.enabled && r.permissions.roles.len() > 1)
else {
return Ok::<_, Error>(None);
};
let (client, _, plan) =
build_plan(db, w_id, datatable_name, Some(&record.permissions), &req).await?;
Ok(Some((client, plan, key)))
}
.await;
match res {
Ok(planned) => planned,
Err(e) => {
tracing::error!(
"Could not plan dropping the Postgres roles of deleted data table {datatable_name} in {w_id}: {e:#}"
"Could not plan dropping the Postgres roles behind data table {datatable_name} in {w_id}: {e:#}"
);
None
}
}
}
/// The Postgres logins the workspace's config currently names.
fn pg_rolenames_in_use(settings: Option<&serde_json::Value>) -> HashSet<String> {
settings
.and_then(|s| s.get("datatables"))
.and_then(|d| d.as_object())
.map(|datatables| {
datatables
.values()
.filter_map(|dt| dt.pointer("/permissions/roles")?.as_object())
.flat_map(|roles| roles.values())
.filter_map(|role| role.get("pg_rolename")?.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
/// Destroy the roles a committed config stopped naming.
/// Destroy the roles a committed save stopped naming.
///
/// Asked under the settings row, one role at a time: a role the config names
/// again — a data table recreated under the same name, or a save that put the
/// role back — is left alone, and one it does not name is dropped whatever else
/// has changed in the meantime. Nobody else can be planning against these
/// between the question and the answer, since that row is what every save takes
/// first.
/// Asked under the database's permissions row, one role at a time: a role the
/// row names again — a save that put the role back — is left alone, and one it
/// does not name is dropped whatever else has changed in the meantime. Nobody
/// else can be planning against these between the question and the answer,
/// since that row is what every save takes first.
///
/// Best-effort: a role outliving its config is recoverable, dropping one a live
/// data table depends on is not.
async fn drop_roles_the_config_no_longer_names(
/// Best-effort: a role outliving its row is recoverable, dropping one a live
/// role depends on is not.
async fn drop_roles_the_record_no_longer_names(
db: &DB,
w_id: &str,
database_key: &str,
client: &mut tokio_postgres::Client,
statements: &[&PlannedStatement],
) -> Result<()> {
let mut tx = db.begin().await?;
let settings =
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, w_id).await?;
let in_use = pg_rolenames_in_use(settings.as_ref());
let in_use: HashSet<String> = lock_database_permissions(&mut tx, database_key)
.await?
.map(|r| r.permissions)
.filter(|p| p.enabled)
.into_iter()
.flat_map(|p| p.roles.into_values())
.filter_map(|role| role.pg_rolename)
.collect();
let to_run: Vec<&PlannedStatement> = statements
.iter()
.filter(|s| {
@@ -623,10 +595,9 @@ async fn drop_roles_the_config_no_longer_names(
attempted.sort();
attempted.dedup();
let ran = async {
// The settings row is held for as long as these run, and they run on a
// database this workspace does not control — a lock held there, or a
// role with a great deal to reassign, would otherwise stall every save
// of every data table in the workspace behind it.
// The row is held for as long as these run, and they run on a database
// Windmill does not control — a lock held there, or a role with a great
// deal to reassign, would otherwise stall every save behind it.
client
.batch_execute("SET statement_timeout = '60s'")
.await
@@ -640,9 +611,9 @@ async fn drop_roles_the_config_no_longer_names(
}
.await;
// Named here rather than by the caller, and on every way out: which of
// them were skipped because the config names them again is only known
// under the lock above, and whatever failed, the config that stopped
// naming these has committed and nothing comes back for them.
// them were skipped because the row names them again is only known under
// the lock above, and whatever failed, the save that stopped naming these
// has committed and nothing comes back for them.
ran.map_err(|e| {
Error::ExecutionErr(format!("{e}. Roles left behind: {}", attempted.join(", ")))
})?;
@@ -651,20 +622,54 @@ async fn drop_roles_the_config_no_longer_names(
Ok(())
}
/// Drop the roles of a data table that was deleted, once the config saying so
/// has committed.
/// Drop the roles of a database that is going away, once the deletion saying so
/// has committed, and forget its permissions.
pub(crate) async fn run_planned_drop(
db: &DB,
w_id: &str,
datatable_name: &str,
(mut client, plan): PlannedRoleDrop,
planned: PlannedRoleDrop,
) {
let key = planned.2.clone();
if run_planned_drop_keeping_record(db, w_id, datatable_name, planned).await {
forget_database_permissions(db, &key).await;
}
}
/// Drop the roles a plan names, leaving the database's permissions row in place:
/// with its logins gone every role is refused and `admin` stays the owning
/// workspace's alone, which is the safe state for a database that was meant to go
/// and did not. Returns whether the roles were dropped.
pub(crate) async fn run_planned_drop_keeping_record(
db: &DB,
w_id: &str,
datatable_name: &str,
(mut client, plan, database_key): PlannedRoleDrop,
) -> bool {
let statements: Vec<&PlannedStatement> = plan.statements.iter().collect();
if let Err(e) = drop_roles_the_config_no_longer_names(db, w_id, &mut client, &statements).await
{
tracing::error!(
"Could not drop the Postgres roles of deleted data table {datatable_name} in {w_id}: {e:#}"
);
match drop_roles_the_record_no_longer_names(db, &database_key, &mut client, &statements).await {
Ok(()) => true,
Err(e) => {
tracing::error!(
"Could not drop the Postgres roles behind data table {datatable_name} in {w_id}: {e:#}"
);
false
}
}
}
/// Forget a database's permissions: for a database that is gone, roles and all.
pub(crate) async fn forget_database_permissions(db: &DB, database_key: &str) {
let forgotten = async {
let mut tx = db.begin().await?;
lock_database_permissions(&mut tx, database_key).await?;
delete_database_permissions(&mut tx, database_key).await?;
tx.commit().await?;
Ok::<(), Error>(())
}
.await;
if let Err(e) = forgotten {
tracing::error!("Could not forget the permissions of {database_key}: {e:#}");
}
}
@@ -697,18 +702,52 @@ async fn run_statements(
})
}
async fn get_datatable_permissions(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatablePermissionsInfo> {
/// The permissions the caller may manage from `w_id` for the database a data
/// table reaches: an admin of the owning workspace, or a superadmin. A record
/// that does not exist yet is created by the workspace that opts in — not from a
/// fork, whose data table is either a copy of a database another workspace owns
/// or a clone the fork can drop, roles and all.
async fn ensure_can_manage_permissions(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
record: Option<&DatabasePermissions>,
enabling: bool,
) -> Result<()> {
require_admin(authed.is_admin, &authed.username)?;
let datatable = read_datatable_unchecked(&db, &w_id, &datatable_name).await?;
let permissions = datatable.permissions.unwrap_or_default();
let default_role = permissions.default_role().to_string();
Ok(Json(DatatablePermissionsInfo {
match record {
Some(record) if record.owner_workspace_id != w_id => {
if !windmill_common::auth::is_super_admin_email(db, &authed.email).await? {
return Err(Error::NotAuthorized(format!(
"The permissions of this database are managed from workspace '{}', \
which turned them on.",
record.owner_workspace_id
)));
}
}
Some(_) => {}
None => {
if enabling && crate::workspaces_extra::workspace_is_fork(db, w_id).await? {
return Err(Error::BadRequest(
"Data table permissions cannot be enabled from a fork workspace: 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."
.to_string(),
));
}
}
}
Ok(())
}
fn permissions_info(
record: Option<&DatabasePermissions>,
editable: bool,
) -> DatatablePermissionsInfo {
let permissions = record.map(|r| r.permissions.clone()).unwrap_or_default();
DatatablePermissionsInfo {
enabled: permissions.enabled,
default_role,
default_role: permissions.default_role().to_string(),
roles: permissions
.roles
.into_iter()
@@ -718,7 +757,23 @@ async fn get_datatable_permissions(
pg_rolename: role.pg_rolename,
})
.collect(),
}))
owner_workspace_id: record.map(|r| r.owner_workspace_id.clone()),
editable,
}
}
async fn get_datatable_permissions(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatablePermissionsInfo> {
require_admin(authed.is_admin, &authed.username)?;
let (_, _, key) = resolve_datatable_database_unchecked(&db, &w_id, &datatable_name).await?;
let record = database_permissions_by_key(&db, &key).await?;
let editable = ensure_can_manage_permissions(&db, &authed, &w_id, record.as_ref(), false)
.await
.is_ok();
Ok(Json(permissions_info(record.as_ref(), editable)))
}
/// Refuse a data table operation that would run as a role `authed` may not use.
@@ -734,8 +789,11 @@ pub(crate) async fn ensure_can_use_datatable_role(
authed: &ApiAuthed,
context: &str,
) -> Result<()> {
let datatable = read_datatable_unchecked(db, w_id, datatable_name).await?;
let Some(permissions) = datatable.permissions.filter(|p| p.enabled) else {
let (_, _, key) = resolve_datatable_database_unchecked(db, w_id, datatable_name).await?;
let Some(record) = database_permissions_by_key(db, &key)
.await?
.filter(|r| r.permissions.enabled)
else {
// Unpermissioned: only the built-in role exists, and everyone reaches it.
return match role {
Some(role) if role != ADMIN_DATATABLE_ROLE => Err(Error::BadRequest(format!(
@@ -744,13 +802,21 @@ pub(crate) async fn ensure_can_use_datatable_role(
_ => Ok(()),
};
};
let role_name = role.unwrap_or_else(|| permissions.default_role());
let entry = permissions.roles.get(role_name).ok_or_else(|| {
let role_name = role.unwrap_or_else(|| record.permissions.default_role());
let entry = record.permissions.roles.get(role_name).ok_or_else(|| {
Error::NotFound(format!(
"{context} names role '{role_name}', which is not defined on data table '{datatable_name}'"
))
})?;
if !can_use_datatable_role(entry, &authed.to_authed_ref()) {
let allowed = can_use_datatable_role_in_owner_workspace(
db,
&record.owner_workspace_id,
w_id,
entry,
&DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
if !allowed {
return Err(Error::NotAuthorized(format!(
"{context} runs as role '{role_name}' of data table '{datatable_name}', which you are not allowed to use"
)));
@@ -758,298 +824,28 @@ pub(crate) async fn ensure_can_use_datatable_role(
Ok(())
}
/// Permissions are turned on where this data table is the only one reaching the
/// database: its workspace is not a fork, no fork below holds a copy of it, and
/// no other data table entry, in any workspace, reaches the same database.
///
/// A fork's data table is either a copy pointing at the database of the workspace
/// it was forked from, where roles created in the fork would hold grants that
/// workspace's own config does not name, or a clone whose whole database the fork
/// can drop, taking the roles with it. In the other direction, a fork made while
/// the data table was unpermissioned carries a verbatim copy of it, and every
/// member of that fork — including members this workspace does not have — would
/// keep reaching the database through the copy's own connection, which owns
/// everything in it. A dev workspace detached from this one keeps such a copy
/// without being a fork any more, which is what the last check is for. Another
/// entry naming the same instance database is exact; a resource-backed entry
/// holds a resource of its own, under whatever path, so it counts when that
/// resource resolves to the same host, port, database and user — which is what
/// a detached copy's cloned resource does, wherever it was moved since.
///
/// Archived workspaces count. Archiving keeps the members, their session tokens
/// and the settings, and nothing on the job path checks the flag, so an archived
/// fork reaches the database exactly as a live one does. Only a permanent
/// deletion, or removing the copy, takes that away. The shell a rename archives
/// is left with no data tables at all, so it never counts.
///
/// All three are properties of the opt-in, so only the save that turns
/// permissions on is checked. Forks made afterwards never receive a permissioned
/// data table (see the strip in the fork creation), and a save that edits the
/// roles of a live config must keep working while they exist — revoking a tenant
/// above all. Turning permissions off is never refused.
///
/// The save calls this under the settings row lock, which fork creation takes on
/// the parent before copying its settings: a fork mid-creation has either
/// committed, and is listed here, or copies the config after the opt-in landed.
async fn refuse_enabling_permissions_over_shared_access(
/// The roles of a database `access`, made from `w_id`, may run as.
pub(crate) async fn usable_roles(
db: &DB,
w_id: &str,
datatable_name: &str,
enabled: bool,
) -> Result<()> {
if !enabled {
return Ok(());
}
let datatable = read_datatable_unchecked(db, w_id, datatable_name).await?;
if datatable.permissions.as_ref().is_some_and(|p| p.enabled) {
return Ok(());
}
if crate::workspaces_extra::workspace_is_fork(db, w_id).await? {
return Err(Error::BadRequest(
"Data table permissions cannot be enabled from a fork workspace: a fork's data \
table points either at the database of the workspace it was forked from, where \
roles created here would be invisible to that workspace's own configuration, or \
at a copy the fork can drop. Set them where the data table belongs. Disabling \
them here is allowed."
.to_string(),
));
}
let database = serde_json::to_value(&datatable.database)
.map_err(|e| Error::internal_err(format!("Failed to serialize the database: {e}")))?;
let describe = |workspace_id: &str, name: &str, deleted: bool| {
format!(
"{workspace_id}{} (data table '{name}')",
if deleted { ", archived" } else { "" }
)
};
let forks = windmill_common::workspaces::list_fork_descendants(db, w_id).await?;
if !forks.is_empty() {
// A clone (`forked_from`) points at a database of its own and does not
// count. It is not told apart by the pointer alone: cloning a
// resource-backed data table rewrites the cloned resource, not the path
// the entry names, so the pointer still equals the parent's.
let copies = sqlx::query!(
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "name!", w.deleted AS "deleted!"
FROM workspace_settings ws
JOIN workspace w ON w.id = ws.workspace_id,
jsonb_each(ws.datatable->'datatables') dt
WHERE ws.workspace_id = ANY($1)
AND dt.value->'database' = $2
AND dt.value->'forked_from' IS NULL
ORDER BY ws.workspace_id, dt.key"#,
&forks[..],
database,
)
.fetch_all(db)
.await?;
if !copies.is_empty() {
let copies: Vec<String> = copies
.into_iter()
.map(|c| describe(&c.workspace_id, &c.name, c.deleted))
.collect();
return Err(Error::BadRequest(format!(
"Data table permissions cannot be enabled while a fork of this workspace holds a \
copy of the data table pointing at the same database: {}. Its members would \
keep reaching it through the copy's own connection, as every role at once — an \
archived fork included, since archiving keeps its members. Remove the data \
table from the fork, or delete the fork permanently, first.",
copies.join(", ")
)));
}
}
let others = entries_reaching(db, w_id, datatable_name, &datatable.database).await?;
if !others.is_empty() {
return Err(Error::BadRequest(format!(
"Data table permissions cannot be enabled while another data table reaches the \
same database: {}. Its users would keep reaching it through that data table's \
own connection, as every role at once — an archived workspace included, since \
archiving keeps its members. Remove that data table, or delete the workspace \
permanently, first.",
others.join(", ")
)));
}
Ok(())
}
/// `<workspace>[, archived] (data table '<name>')` for every data table entry on
/// the instance, other than `(w_id, datatable_name)`, that reaches `database` —
/// this workspace's other entries included: a second entry on the same
/// resource is a second door.
///
/// An instance database is matched by name. A resource-backed entry reaches
/// wherever its resource points, whatever path it names and whichever login it
/// carries, so it is matched by the resource's host, port and database. Read
/// from the stored resource rows
/// in one query: a field that is a `$var:` / `$res:` reference is only resolved
/// — a per-row read, and a secret backend call where the workspace uses one —
/// when every plain field already agrees, so the loop that decrypts runs for
/// candidates that can match and not for every data table on the instance. A
/// resource that no longer exists reaches nothing.
async fn entries_reaching(
db: &DB,
w_id: &str,
datatable_name: &str,
database: &windmill_common::workspaces::DataTableDatabase,
record: &DatabasePermissions,
access: &DatatableAccess<'_>,
) -> Result<Vec<String>> {
let pointer = serde_json::to_value(database)
.map_err(|e| Error::internal_err(format!("Failed to serialize the database: {e}")))?;
let describe = |workspace_id: &str, name: &str, deleted: bool| {
format!(
"{workspace_id}{} (data table '{name}')",
if deleted { ", archived" } else { "" }
let mut usable = Vec::new();
for (name, role) in record.permissions.roles.iter() {
if can_use_datatable_role_in_owner_workspace(
db,
&record.owner_workspace_id,
w_id,
role,
access,
)
};
let entries = sqlx::query!(
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "name!", w.deleted AS "deleted!",
dt.value->'database' AS "database!", r.value AS "resource?"
FROM workspace_settings ws
JOIN workspace w ON w.id = ws.workspace_id
CROSS JOIN LATERAL jsonb_each(ws.datatable->'datatables') dt
LEFT JOIN resource r ON r.workspace_id = ws.workspace_id
AND dt.value->'database'->>'resource_type' <> 'instance'
AND r.path = dt.value->'database'->>'resource_path'
WHERE NOT (ws.workspace_id = $1 AND dt.key = $2)
AND jsonb_typeof(dt.value->'database') = 'object'
ORDER BY ws.workspace_id, dt.key"#,
w_id,
datatable_name,
)
.fetch_all(db)
.await?;
let mut reaching = Vec::new();
match database.resource_type {
DataTableCatalogResourceType::Instance => {
reaching.extend(
entries
.iter()
.filter(|o| o.database == pointer)
.map(|o| describe(&o.workspace_id, &o.name, o.deleted)),
);
}
DataTableCatalogResourceType::Postgresql => {
let ours = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
let identity = physical_database_identity(&ours);
let is_reference = |v: &serde_json::Value| {
v.as_str()
.is_some_and(|s| s.starts_with("$var:") || s.starts_with("$res:"))
};
for o in entries.iter() {
let Some(raw) = o.resource.as_ref() else {
continue;
};
// A value that is not an object — a `$res:` string, say — only
// says what it reaches once resolved.
let mut references = !raw.is_object();
let mut plain_fields_agree = true;
for field in PHYSICAL_DATABASE_FIELDS {
let theirs = raw.get(field).unwrap_or(&serde_json::Value::Null);
if is_reference(theirs) {
references = true;
} else if !references
&& theirs != ours.get(field).unwrap_or(&serde_json::Value::Null)
{
plain_fields_agree = false;
}
}
if !plain_fields_agree {
continue;
}
if !references {
reaching.push(describe(&o.workspace_id, &o.name, o.deleted));
continue;
}
let resolved =
match get_datatable_resource_from_db_unchecked(db, &o.workspace_id, &o.name)
.await
{
Ok(resolved) => resolved,
Err(Error::NotFound(_)) => continue,
// Not resolving is not proof of not reaching: refused, and
// named.
Err(e) => {
return Err(Error::BadRequest(format!(
"Whether {} reaches the same database could not be checked \
({e}). Remove that data table first.",
describe(&o.workspace_id, &o.name, o.deleted)
)))
}
};
if physical_database_identity(&resolved) == identity {
reaching.push(describe(&o.workspace_id, &o.name, o.deleted));
}
}
.await?
{
usable.push(name.clone());
}
}
Ok(reaching)
}
/// The fields [`physical_database_identity`] hashes.
const PHYSICAL_DATABASE_FIELDS: [&str; 3] = ["host", "port", "dbname"];
/// Refuse a data table entry that reaches a database another data table governs.
///
/// The opt-in refuses while any other entry reaches the database; this is the
/// same rule from the other side, for the settings form that adds an entry or
/// points one elsewhere. Without it a second entry on a governed database is a
/// second door, open to every member as the owning connection. Governed entries
/// are matched by the identity their opt-in stamped, so nothing of theirs is
/// resolved; the entry being saved is resolved once, and must resolve.
pub(crate) async fn refuse_reaching_a_governed_database(
db: &DB,
w_id: &str,
datatable_name: &str,
database: &windmill_common::workspaces::DataTableDatabase,
) -> Result<()> {
let hit = match database.resource_type {
DataTableCatalogResourceType::Instance => {
let pointer = serde_json::to_value(database).map_err(|e| {
Error::internal_err(format!("Failed to serialize the database: {e}"))
})?;
sqlx::query!(
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "name!"
FROM workspace_settings ws, jsonb_each(ws.datatable->'datatables') dt
WHERE NOT (ws.workspace_id = $1 AND dt.key = $2)
AND COALESCE((dt.value->'permissions'->>'enabled')::boolean, false)
AND dt.value->'database' = $3
ORDER BY ws.workspace_id, dt.key"#,
w_id,
datatable_name,
pointer,
)
.fetch_optional(db)
.await?
.map(|g| (g.workspace_id, g.name))
}
DataTableCatalogResourceType::Postgresql => {
// The stored config is what the form is about to replace, so the entry
// is resolved from the resource it names rather than from the config.
let resource = windmill_common::workspaces::transform_json_value_unchecked(
&serde_json::Value::String(format!("$res:{}", database.resource_path)),
w_id,
db,
)
.await?;
windmill_common::workspaces::governed_datatable_reaching(
db,
&physical_database_identity(&resource),
&[(w_id, datatable_name)],
)
.await?
}
};
if let Some((gw, gname)) = hit {
return Err(Error::BadRequest(format!(
"Data table '{datatable_name}' would reach the database of {}, whose role \
permissions are enabled: every member would reach it through this data table's \
own connection, as every role at once.",
if gw == w_id {
format!("data table '{gname}'")
} else {
format!("data table '{gname}' of workspace {gw}")
}
)));
}
Ok(())
Ok(usable)
}
/// Refuse a save that names something that no longer exists: a tenant whose
@@ -1209,24 +1005,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 {
let (_, _, key) = resolve_datatable_database_unchecked(&db, &w_id, &datatable_name).await?;
let Some(record) = database_permissions_by_key(&db, &key)
.await?
.filter(|r| r.permissions.enabled)
else {
return Ok(Json(UsableDatatableRoles {
enabled: false,
roles: vec![],
default_role: ADMIN_DATATABLE_ROLE.to_string(),
}));
};
let authed_ref = authed.to_authed_ref();
let roles = usable_roles(
&db,
&w_id,
&record,
&DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
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))
.map(|(name, _)| name.clone())
.collect(),
default_role: record.permissions.default_role().to_string(),
roles,
}))
}
@@ -1236,12 +1036,19 @@ async fn preview_datatable_permissions(
Path((w_id, datatable_name)): Path<(String, String)>,
Json(req): Json<SetDatatablePermissions>,
) -> JsonResult<DatatablePermissionsPreview> {
require_admin(authed.is_admin, &authed.username)?;
// Refused here too: the preview connects to the database and reads its roles,
// and offering a plan that the save will not run is its own kind of wrong.
refuse_enabling_permissions_over_shared_access(&db, &w_id, &datatable_name, req.enabled)
.await?;
let (_client, plan) = build_plan(&db, &w_id, &datatable_name, &req).await?;
let (_, _, key) = resolve_datatable_database_unchecked(&db, &w_id, &datatable_name).await?;
let record = database_permissions_by_key(&db, &key).await?;
// Refused here too: offering a plan that the save will not run is its own
// kind of wrong.
ensure_can_manage_permissions(&db, &authed, &w_id, record.as_ref(), req.enabled).await?;
let (_client, _, plan) = build_plan(
&db,
&w_id,
&datatable_name,
record.as_ref().map(|r| &r.permissions),
&req,
)
.await?;
Ok(Json(DatatablePermissionsPreview {
statements: plan.statements.into_iter().map(|s| s.display).collect(),
warnings: plan.warnings,
@@ -1255,16 +1062,21 @@ async fn set_datatable_permissions(
Json(req): Json<SetDatatablePermissions>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let (_, _, key) = resolve_datatable_database_unchecked(&db, &w_id, &datatable_name).await?;
// Reading the config, planning against it, running the plan and persisting
// it are one operation: interleaved with another save, or with the removal
// of a principal some role names as a tenant, this would store a block it
// computed before the other committed. The settings row is what everything
// touching that config takes, so taking it here is what serializes them.
// Reading the permissions, planning against them, running the plan and
// persisting them are one operation: interleaved with another save, or with
// the removal of a principal some role names as a tenant, this would store
// roles it computed before the other committed. The database's row is what
// everything touching its permissions takes, so taking it here is what
// serializes them — whether or not the row exists yet.
let mut tx = db.begin().await?;
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, &w_id).await?;
refuse_enabling_permissions_over_shared_access(&db, &w_id, &datatable_name, req.enabled)
.await?;
let record = lock_database_permissions(&mut tx, &key).await?;
ensure_can_manage_permissions(&db, &authed, &w_id, record.as_ref(), req.enabled).await?;
let owner_workspace_id = record
.as_ref()
.map(|r| r.owner_workspace_id.clone())
.unwrap_or_else(|| w_id.clone());
// The roles about to be created are handed privileges by this connection,
// which cannot pass on what it holds without the grant option.
@@ -1272,12 +1084,19 @@ async fn set_datatable_permissions(
// The plan is rebuilt here rather than trusted from the preview: the client
// never gets to choose what runs against the database.
let (mut client, plan) = build_plan(&db, &w_id, &datatable_name, &req).await?;
let (mut client, _, plan) = build_plan(
&db,
&w_id,
&datatable_name,
record.as_ref().map(|r| &r.permissions),
&req,
)
.await?;
// Creating and renaming roles is committed before the config: a Windmill-side
// failure after this point leaves roles the config does not know about, which
// Creating and renaming roles is committed before the row: a Windmill-side
// failure after this point leaves roles the row does not know about, which
// the next plan adopts (it reads `pg_roles`), whereas the reverse order would
// leave the config naming roles that were never created. Dropping one has no
// leave the row naming roles that were never created. Dropping one has no
// such way back, so those wait below — except where this save gives the freed
// name to another role, which only works in one order.
let keeps_the_name = |statement: &PlannedStatement| {
@@ -1295,26 +1114,10 @@ async fn set_datatable_permissions(
.partition(|s| s.drops_role.is_some() && !keeps_the_name(s));
run_statements(&mut client, &immediate).await?;
let permissions = serde_json::to_value(&plan.permissions)
.map_err(|e| Error::internal_err(format!("Failed to serialize permissions: {e}")))?;
// Written at the permissions path only, so a concurrent edit of the data
// table's own settings is not clobbered.
let updated = sqlx::query_scalar!(
"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)
WHERE workspace_id = $1 AND datatable->'datatables' ? $2
RETURNING workspace_id",
&w_id,
&datatable_name,
permissions,
)
.fetch_optional(&mut *tx)
.await?;
if updated.is_none() {
return Err(Error::NotFound(format!(
"Data table '{datatable_name}' not found"
)));
if req.enabled {
upsert_database_permissions(&mut tx, &key, &owner_workspace_id, &plan.permissions).await?;
} else {
delete_database_permissions(&mut tx, &key).await?;
}
audit_log(
@@ -1327,6 +1130,7 @@ async fn set_datatable_permissions(
Some(
[
("datatable", datatable_name.as_str()),
("database", key.as_str()),
("enabled", if req.enabled { "true" } else { "false" }),
]
.into(),
@@ -1336,24 +1140,21 @@ async fn set_datatable_permissions(
tx.commit().await?;
// 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.
// What the row no longer names, now that it says so. A failure here is the
// end of the line for these logins: the save that stopped naming 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)
drop_roles_the_record_no_longer_names(&db, &key, &mut client, &deferred)
.await
.map_err(|e| {
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, so they have to be dropped by \
hand."
"The permissions were saved, but some roles could not be dropped: {e}"
))
})?;
}
Ok(format!(
"Updated permissions of data table {datatable_name}"
"Permissions of data table {datatable_name} updated"
))
}
@@ -28,8 +28,7 @@ use {
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn plan_role_changes(
_w_id: &str,
_datatable: &str,
_database_key: &str,
_dbname: &str,
_admin_pg_role: &str,
_old: Option<&DataTablePermissions>,
+50 -219
View File
@@ -45,12 +45,11 @@ use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
use windmill_common::workspaces::{
can_use_datatable_role, check_deploy_rules, check_user_against_rule,
get_datatable_resource_from_db, get_datatable_resource_from_db_unchecked,
redact_datatable_settings_for_export, 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,
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;
@@ -364,8 +363,6 @@ pub struct WorkspacePublicSettings {
pub deploy_ui: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub large_file_storage: Option<serde_json::Value>,
/// Carries each data table role's generated login as stored, so it only
/// leaves the server through `redact_datatable_settings_for_export`.
#[serde(skip_serializing_if = "Option::is_none")]
pub datatable: Option<serde_json::Value>,
}
@@ -1117,7 +1114,6 @@ async fn get_settings(
if let Some(git_sync) = settings.git_sync.as_mut() {
redact_git_sync_webhook_secrets(git_sync);
}
settings.datatable = redact_datatable_settings_for_export(settings.datatable);
Ok(Json(settings))
}
@@ -1154,12 +1150,11 @@ async fn get_public_settings(
.await
.map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
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.
settings.datatable = redact_datatable_settings_for_export(settings.datatable);
Ok(Json(settings))
}
@@ -2452,42 +2447,38 @@ async fn list_datatable_tables(
}
/// Which roles the caller may use on each data table of the workspace, and the
/// one they get by default. Read in one go: the tree lists every data table, and
/// this is config only, so it costs a single query rather than one per table.
/// 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 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
.into_iter()
.map(|(name, dt)| {
let info = match dt.permissions.filter(|p| p.enabled) {
Some(p) => (
p.roles
.iter()
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
.map(|(role_name, _)| role_name.clone())
.collect(),
p.default_role().to_string(),
),
None => (vec![], ADMIN_DATATABLE_ROLE.to_string()),
};
(name, info)
})
.collect())
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(
@@ -3138,46 +3129,6 @@ pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Re
.unwrap_or(false))
}
/// Refuse to clone a data table whose role permissions are enabled.
///
/// 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 resolves to the copy's own owner connection and reads, in full, the
/// data the roles existed to divide. Reproducing the roles in the copy is a
/// separate piece of work; until it exists, a fork goes without the data table
/// (the fork creation leaves a permissioned one out of the fork's config).
///
/// Authorization: performs none. It reads whether `w_id`'s data table is
/// permissioned, for any `w_id` it is handed, so callers MUST already have
/// authorized the caller for that workspace.
pub(crate) async fn refuse_clone_of_permissioned_datatable(
db: &DB,
w_id: &str,
source: &str,
) -> Result<()> {
let Some(name) = source.strip_prefix("datatable://") else {
return Ok(());
};
let enabled = sqlx::query_scalar!(
"SELECT COALESCE((datatable->'datatables'->$2->'permissions'->>'enabled')::boolean, false)
FROM workspace_settings WHERE workspace_id = $1",
w_id,
name,
)
.fetch_optional(db)
.await?
.flatten()
.unwrap_or(false);
if enabled {
return Err(Error::BadRequest(format!(
"Data table '{name}' has role permissions enabled and cannot be cloned into a fork: \
the copy cannot carry its roles, so it would be readable in full by every member of \
the fork. The fork goes without it; disable its permissions first to clone it."
)));
}
Ok(())
}
/// 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> {
@@ -3506,7 +3457,6 @@ async fn create_pg_database(
Json(req): Json<CreatePgDatabaseRequest>,
) -> Result<String> {
windmill_common::validate_dbname(&req.target_dbname)?;
refuse_clone_of_permissioned_datatable(&db, &w_id, &req.source).await?;
// Non-superadmin: restrict dbname to wm_fork_ prefix
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
@@ -3598,13 +3548,6 @@ async fn import_pg_database(
}
}
// 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, and lands nowhere that strips permissions.
if req.target_dbname_override.is_some() {
refuse_clone_of_permissioned_datatable(&db, &w_id, &req.source).await?;
}
let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly;
let source_pg = resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.source).await?;
let mut target_pg =
@@ -3800,15 +3743,15 @@ async fn edit_datatable_config(
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
let mut tx = db.begin().await?;
// This form carries the whole config forward — permissions restored from the
// old value included — so it reads and writes the settings under the same
// lock as the role save and the principal cleanups, or it puts back what one
// of them just took away.
let old_datatables: HashMap<String, DataTable> = serde_json::from_value(
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, &w_id)
.await?
.and_then(|d| d.get("datatables").cloned())
.unwrap_or(serde_json::Value::Null),
sqlx::query_scalar!(
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
&w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten()
.unwrap_or(serde_json::Value::Null),
)
.unwrap_or_default();
@@ -3860,12 +3803,10 @@ async fn edit_datatable_config(
Some(true)
}
};
// Same for permissions, owned by the datatable_permissions endpoints.
// `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.permissions = old.and_then(|old| old.permissions.clone());
// `forked_from` is stamped by the fork clone and read by the permissions
// opt-in as "this entry has a database of its own": the form may update the
// schema snapshot inside it, never add or remove the stamp.
dt.forked_from = match (
old.and_then(|old| old.forked_from.as_ref()),
dt.forked_from.take(),
@@ -3876,55 +3817,9 @@ async fn edit_datatable_config(
schema: old.schema.clone(),
}),
};
// An entry that is new, or points somewhere new, must not reach a database
// another data table governs.
let points_elsewhere = old.is_none_or(|old| {
old.database.resource_path != dt.database.resource_path
|| old.database.resource_type != dt.database.resource_type
});
if points_elsewhere {
crate::datatable_permissions::refuse_reaching_a_governed_database(
&db,
&w_id,
name,
&dt.database,
)
.await?;
}
// The roles live in the database this data table points at: their 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 the grants, so the switch has to go through opting out first —
// which is also what drops the roles from the database they belong to.
if let Some(old) = old.filter(|_| dt.permissions.as_ref().is_some_and(|p| p.enabled)) {
if old.database.resource_path != dt.database.resource_path
|| old.database.resource_type != dt.database.resource_type
{
return Err(Error::BadRequest(format!(
"Data table '{name}' has permissions enabled, so it cannot be pointed at \
another database: disable them first, which drops its roles from the \
database they were created in."
)));
}
// A generated login is named from the data table's name, so a renamed
// one keeps logins named for the name it left — and a data table
// created under that name next generates those same names, adopts
// those logins and resets their passwords. Renaming is rare; sharing
// a login between two data tables is not something to leave open.
if lookup != name.as_str() {
return Err(Error::BadRequest(format!(
"Data table '{lookup}' has permissions enabled, so it cannot be renamed to \
'{name}': its Postgres logins are named after '{lookup}' and a data table \
created under that name would take them over. Disable its permissions \
first, which drops those logins, then rename and enable them again."
)));
}
}
}
// The settings carry each role's generated login password.
let args_for_audit =
windmill_common::workspaces::datatable_settings_for_audit(&new_config.settings);
let args_for_audit = serde_json::to_string(&new_config.settings).unwrap_or_default();
audit_log(
&mut *tx,
&authed,
@@ -3955,27 +3850,6 @@ async fn edit_datatable_config(
}
}
// Planned before the config is overwritten, while the data tables about to
// disappear can still be resolved to a connection, and run once it has
// committed: `DROP OWNED` discards the roles' grants for good, so a save that
// rolls back after this point must not have destroyed anything.
//
// What is disappearing is read from the two configs rather than from the
// request's `deleted_datatables`: a save that drops a name and adds another
// declares no rename and no deletion, and the logins of the name it dropped
// would be left for whatever data table is created under it next.
let mut planned_role_drops = Vec::new();
for gone in old_datatables
.keys()
.filter(|name| !new_config.settings.datatables.contains_key(*name))
{
if let Some(planned) =
crate::datatable_permissions::plan_drop_of_deleted_datatable(&db, &w_id, gone).await
{
planned_role_drops.push((gone.clone(), planned));
}
}
let config: serde_json::Value = serde_json::to_value(new_config.settings)
.map_err(|err| Error::internal_err(err.to_string()))?;
@@ -3999,10 +3873,6 @@ async fn edit_datatable_config(
tx.commit().await?;
for (deleted, planned) in planned_role_drops {
crate::datatable_permissions::run_planned_drop(&db, &w_id, &deleted, planned).await;
}
for substrate in created_substrates {
windmill_common::feature_usage::log_feature_usage("datatable", "created", substrate);
}
@@ -7851,10 +7721,6 @@ 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.
refuse_clone_of_permissioned_datatable(db, parent_w_id, &format!("datatable://{}", 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_'",
@@ -8288,14 +8154,6 @@ async fn create_workspace_fork(
.await?;
}
// Enabling a data table's permissions is refused while a fork holds a copy of it, and
// that check runs under this same row: a fork still being created has either committed,
// and is found, or copies the parent's settings only after the opt-in landed, so the
// permissioned data table is stripped from it below. Pairing lock first, as
// `lock_workspace_settings_unchecked` states.
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, &parent_workspace_id)
.await?;
let forked_id = nw.id;
sqlx::query!(
@@ -8388,21 +8246,10 @@ async fn create_workspace_fork(
apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?;
}
// 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, 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.
//
// A copy that was not forked here also loses any `forked_from` it inherited: the stamp
// means "cloned into this workspace's own database", which is what the permissions
// opt-in reads it as, and a copy of the parent's clone points where the parent points.
// 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()
@@ -8413,11 +8260,9 @@ async fn create_workspace_fork(
SET datatable = jsonb_set(datatable, '{datatables}', (
SELECT COALESCE(jsonb_object_agg(
key,
CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value - 'forked_from' END
CASE WHEN key = ANY($2) THEN value ELSE value - 'forked_from' 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,
@@ -8426,20 +8271,6 @@ async fn create_workspace_fork(
.execute(&mut *tx)
.await?;
// The migrations were cloned for every data table; the ones left out above
// would otherwise keep a history for a data table the fork does not have.
sqlx::query!(
r#"DELETE FROM datatable_migrations m
WHERE m.workspace_id = $1
AND NOT EXISTS (
SELECT 1 FROM workspace_settings ws
WHERE ws.workspace_id = $1 AND ws.datatable->'datatables' ? m.datatable
)"#,
&forked_id,
)
.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.
@@ -62,11 +62,10 @@ 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 settings are copied below, and a permissions save holds this row while it changes
// the roles in the database and then the config: copied without it, the new workspace
// could carry the config from before that save while the database has the roles from
// after it. Pairing lock first, as `lock_workspace_settings_unchecked` states.
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, &old_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?;
@@ -126,24 +125,11 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
// The archived copy keeps no data tables. Two configs would otherwise name the same
// databases and, for a permissioned one, the same Postgres logins, which only the renamed
// workspace owns: deleting the archived id would plan drops for logins still in use, and
// — since this transaction commits before the old id is archived — a copy without its
// `permissions` block would hand every caller of the old id the owner connection in
// between. An archived shell that still named an instance database would also keep the
// renamed workspace from ever opting in, as another workspace reaching the same database.
// A missing data table fails closed.
//
// The renamed workspace keeps the roles and keeps working: a role's `pg_rolename` is
// what resolution uses, and the generated name only decides what a *new* role is called.
// Its next permissions save finds the stored name no longer matches the one this
// workspace id generates and renames the login to match, under the ownership proof that
// rename already carries.
// 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 workspace_settings
SET datatable = jsonb_set(datatable, '{datatables}', '{}'::jsonb)
WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
"UPDATE datatable_database_permissions SET owner_workspace_id = $1 WHERE owner_workspace_id = $2",
&rw.new_id,
&old_id,
)
.execute(&mut *tx)
@@ -1007,17 +993,21 @@ pub(crate) async fn delete_workspace(
vec![]
});
// Same shape, same reason: a permissioned data table's logins live in a
// database whose only record is the settings row below, so what to drop is
// resolved while that row is here and the drop itself runs after the commit.
// Nothing is dropped here.
// The first row this transaction locks — see `lock_workspace_settings_unchecked`.
// Without it a permissions save that commits between this read and the row's
// deletion adds a login nothing then drops, and the config that named it is
// gone.
// 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();
let datatable_config =
windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, &w_id).await?;
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"))
@@ -1026,7 +1016,7 @@ pub(crate) async fn delete_workspace(
.unwrap_or_default()
{
if let Some(planned) =
crate::datatable_permissions::plan_drop_of_deleted_datatable(&db, &w_id, &name).await
crate::datatable_permissions::plan_drop_of_datatable_roles(&db, &w_id, &name).await
{
planned_role_drops.push((name, planned));
}
@@ -1358,94 +1348,23 @@ pub struct DropForkedDatatableDatabasesRequest {
datatable_names: Vec<String>,
}
/// Take a data table's generated logins away before its database goes.
///
/// A login is cluster-wide, so it outlives the database it was created in and
/// stays adoptable by whatever takes this workspace's id and this data table's
/// name next. The config stops naming them first: a data table whose database is
/// being dropped has no business claiming roles in it, and that is also what the
/// drop reads to know they are nobody's.
///
/// Returns the block it removed, which the caller must put back if the drop it
/// was clearing the way for does not happen: a data table that keeps its
/// database and loses its permissions leaves every member of the workspace
/// resolving to the data table's own connection, which owns everything in it.
/// 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
/// roles were dropped.
async fn drop_datatable_roles_before_its_database(
db: &DB,
w_id: &str,
dt_name: &str,
errors: &mut Vec<String>,
) -> Option<serde_json::Value> {
) -> Option<String> {
let planned =
crate::datatable_permissions::plan_drop_of_deleted_datatable(db, w_id, dt_name).await?;
// Read and clear under the settings lock, so what comes back is what was
// taken away and is what putting it back would restore.
let cleared = async {
let mut tx = db.begin().await?;
let removed = windmill_common::workspaces::lock_workspace_settings_unchecked(&mut tx, w_id)
.await?
.and_then(|c| {
c.get("datatables")
.and_then(|d| d.get(dt_name))
.and_then(|d| d.get("permissions"))
.cloned()
});
sqlx::query!(
"UPDATE workspace_settings
SET datatable = datatable #- ARRAY['datatables', $2, 'permissions']
WHERE workspace_id = $1",
w_id,
dt_name,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok::<_, windmill_common::error::Error>(removed)
}
.await;
match cleared {
Ok(removed) => {
crate::datatable_permissions::run_planned_drop(db, w_id, dt_name, planned).await;
removed
}
Err(e) => {
errors.push(format!(
"Could not clear the permissions of datatable://{}: {}",
dt_name, e
));
None
}
}
}
/// Put back the block a drop that did not happen took away. The logins it names
/// are already gone, so every role now resolves to nothing and is refused — the
/// safe answer, and the one a re-save of the data table's permissions repairs by
/// recreating them.
async fn restore_datatable_permissions(
db: &DB,
w_id: &str,
dt_name: &str,
permissions: serde_json::Value,
errors: &mut Vec<String>,
) {
if let Err(e) = sqlx::query!(
"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)
WHERE workspace_id = $1
AND datatable->'datatables' ? $2",
w_id,
dt_name,
permissions,
)
.execute(db)
.await
{
errors.push(format!(
"Could not restore the permissions of datatable://{}, which is now open to every member of the workspace: {}",
dt_name, e
));
}
crate::datatable_permissions::plan_drop_of_datatable_roles(db, w_id, dt_name).await?;
let key = planned.2.clone();
crate::datatable_permissions::run_planned_drop_keeping_record(db, w_id, dt_name, planned)
.await
.then_some(key)
}
/// Drop forked datatable databases. Returns errors per datatable that failed.
@@ -1510,17 +1429,18 @@ pub async fn drop_forked_datatable_databases(
));
continue;
}
let removed =
drop_datatable_roles_before_its_database(&db, &w_id, dt_name, &mut errors).await;
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
));
if let Some(permissions) = removed {
restore_datatable_permissions(&db, &w_id, dt_name, permissions, &mut errors)
.await;
}
)),
}
} else {
let fork_pg = match crate::workspaces::resolve_pg_source_checked(
@@ -1581,27 +1501,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 });
let removed =
drop_datatable_roles_before_its_database(&db, &w_id, dt_name, &mut errors)
.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
));
if let Some(permissions) = removed {
restore_datatable_permissions(
&db,
&w_id,
dt_name,
permissions,
&mut errors,
)
.await;
}
)),
}
drop(client);
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
+7 -1
View File
@@ -34220,7 +34220,7 @@ components:
type: string
DatatablePermissions:
type: object
required: [enabled, roles, default_role]
required: [enabled, roles, default_role, editable]
properties:
enabled:
type: boolean
@@ -34231,6 +34231,12 @@ components:
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]
+10 -4
View File
@@ -833,10 +833,16 @@ async fn offboard_user_from_workspace<'c>(
new_permissioned_as: &str,
) -> Result<OffboardSummary> {
// Before this transaction locks anything else — see
// `lock_workspace_settings_unchecked`. Everything below reassigns rows a
// rename or a deletion writes while holding this row.
let datatable_settings =
windmill_common::workspaces::lock_workspace_settings_unchecked(tx, w_id).await?;
// `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);
+18 -11
View File
@@ -321,11 +321,17 @@ async fn update_username_in_workpsace<'c>(
w_id: &str,
) -> error::Result<()> {
// Before anything else in the transaction — see
// `lock_workspace_settings_unchecked`. `rename_user` walks memberships in
// `lock_datatable_permissions_unchecked`. `rename_user` walks memberships in
// `workspace_id` order, so a rename spanning workspaces takes their rows in
// that order too.
let datatable_settings =
windmill_common::workspaces::lock_workspace_settings_unchecked(tx, w_id).await?;
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 ----
@@ -944,21 +950,22 @@ async fn update_username_in_workpsace<'c>(
// 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 {
let mut renamed = windmill_common::workspaces::rename_datatable_tenant(
&mut settings,
&format!("u/{old_username}"),
&format!("u/{new_username}"),
);
// 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.
renamed |= windmill_common::workspaces::move_datatable_resource_paths(
if windmill_common::workspaces::move_datatable_resource_paths(
&mut settings,
&format!("u/{old_username}/"),
&format!("u/{new_username}/"),
);
if renamed {
) {
sqlx::query!(
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
settings,
@@ -47,7 +47,6 @@ use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings
use windmill_common::scripts::ScriptRunnableSettingsHandle;
use windmill_common::utils::require_admin;
use windmill_common::variables::decrypt;
use windmill_common::workspaces::redact_datatable_settings_for_export;
use windmill_common::worker::WINDMILL_DIR;
use windmill_common::{
db::UserDB,
@@ -1632,7 +1631,7 @@ pub(crate) async fn tarball_workspace(
mute_critical_alerts: row.mute_critical_alerts,
color: row.color.clone(),
operator_settings: row.operator_settings.clone(),
datatable: redact_datatable_settings_for_export(row.datatable.clone()),
datatable: row.datatable.clone(),
slack_team_id: row.slack_team_id.clone(),
slack_name: row.slack_name.clone(),
slack_command_script: row.slack_command_script.clone(),
@@ -1696,7 +1695,7 @@ pub(crate) async fn tarball_workspace(
mute_critical_alerts: row.mute_critical_alerts,
color: row.color,
operator_settings: row.operator_settings,
datatable: redact_datatable_settings_for_export(row.datatable),
datatable: row.datatable,
slack_team_id: row.slack_team_id,
slack_name: row.slack_name,
slack_command_script: row.slack_command_script,
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1196,27 +1196,6 @@ async fn create_resource(
.await?;
}
if update_if_exists {
// An upsert over an existing row is an edit, so it answers to the same
// rule: a permissioned data table's roles live in the database its
// resource names, and that is not free to move while they exist.
let previous = sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
resource.path,
w_id
)
.fetch_optional(&db)
.await?
.flatten();
let nvalue: serde_json::Value = serde_json::from_str(raw_json.0.get())
.map_err(|e| Error::BadRequest(format!("Invalid resource value: {e}")))?;
windmill_common::workspaces::ensure_resource_identity_change_allowed(
&db,
&w_id,
&resource.path,
previous.as_ref(),
Some(&nvalue),
)
.await?;
sqlx::query!(
"INSERT INTO resource
(workspace_id, path, value, description, resource_type, created_by, edited_at, labels)
@@ -1234,17 +1213,6 @@ async fn create_resource(
.await
.map_err(sanitize_db_error)?;
} else {
// A data table entry may already name this path, its resource deleted since.
let nvalue: serde_json::Value = serde_json::from_str(raw_json.0.get())
.map_err(|e| Error::BadRequest(format!("Invalid resource value: {e}")))?;
windmill_common::workspaces::ensure_resource_identity_change_allowed(
&db,
&w_id,
&resource.path,
None,
Some(&nvalue),
)
.await?;
// Create-only (the default): DO NOTHING + a row-count guard, so a path that appears between
// check_path_conflict above and this insert is rejected rather than overwritten. A plain
// DO UPDATE here would clobber a concurrently-created resource, breaking create-only callers
@@ -1355,7 +1323,6 @@ async fn delete_resource(
{
return Err(Error::PermissionDenied(msg));
}
windmill_common::workspaces::ensure_resource_removal_allowed(&db, &w_id, path).await?;
let mut tx = user_db.begin(&authed).await?;
// Capture resource data for trashbin before deleting
@@ -1662,10 +1629,6 @@ async fn delete_resources_bulk(
{
return Err(Error::PermissionDenied(msg));
}
for path in &request.paths {
windmill_common::workspaces::ensure_resource_removal_allowed(&db, &w_id, path).await?;
}
let mut tx = user_db.begin(&authed).await?;
// Capture resources for trashbin per path before bulk delete, and
@@ -1856,56 +1819,6 @@ async fn update_resource(
return Err(Error::PermissionDenied(msg));
}
// A rename takes the resource out from under whatever names its path — and
// puts it under whatever names the new one.
if let Some(npath) = ns.path.as_deref().filter(|npath| *npath != path) {
windmill_common::workspaces::ensure_resource_removal_allowed(&db, &w_id, path).await?;
let arriving: Option<serde_json::Value> = match ns.value.as_ref() {
Some(v) => Some(
serde_json::from_str(v.get())
.map_err(|e| Error::BadRequest(format!("Invalid resource value: {e}")))?,
),
None => sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten(),
};
windmill_common::workspaces::ensure_resource_identity_change_allowed(
&db,
&w_id,
npath,
None,
arriving.as_ref(),
)
.await?;
}
// Same as `set_resource_value`: the identity a permissioned data table's
// roles were created against is not free to move underneath them.
if let Some(nvalue) = ns.value.as_ref() {
let previous = sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
let nvalue: serde_json::Value = serde_json::from_str(nvalue.get())
.map_err(|e| Error::BadRequest(format!("Invalid resource value: {e}")))?;
windmill_common::workspaces::ensure_resource_identity_change_allowed(
&db,
&w_id,
path,
previous.as_ref(),
Some(&nvalue),
)
.await?;
}
let mut sqlb = SqlBuilder::update_table("resource");
sqlb.and_where_eq("path", "?".bind(&path));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
@@ -2220,23 +2133,6 @@ async fn set_resource_value(
// 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 previous = sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_optional(db)
.await?
.flatten();
windmill_common::workspaces::ensure_resource_identity_change_allowed(
db,
w_id,
path,
previous.as_ref(),
value.as_ref(),
)
.await?;
let mut tx = user_db.clone().begin(authed).await?;
// `RETURNING resource_type` rather than a second lookup: the advisory below has to know the
-14
View File
@@ -744,10 +744,6 @@ async fn delete_variable(
return Err(Error::PermissionDenied(msg));
}
// This takes the resource linked at the same path with it, so it answers to
// the same rule as deleting that resource directly.
windmill_common::workspaces::ensure_resource_removal_allowed(&db, &w_id, path).await?;
// Check if variable is a secret before deleting (for Vault cleanup)
let is_secret = sqlx::query_scalar!(
"SELECT is_secret FROM variable WHERE path = $1 AND workspace_id = $2",
@@ -922,11 +918,6 @@ async fn delete_variables_bulk(
return Err(Error::PermissionDenied(msg));
}
// Each of these takes the resource linked at the same path with it.
for path in &request.paths {
windmill_common::workspaces::ensure_resource_removal_allowed(&db, &w_id, path).await?;
}
// Query which paths are secrets before deletion (for Vault cleanup)
let secret_paths: Vec<String> = sqlx::query_scalar!(
"SELECT path FROM variable WHERE path = ANY($1) AND workspace_id = $2 AND is_secret = true",
@@ -1108,11 +1099,6 @@ async fn update_variable(
// source path.
if let Some(npath) = ns.path.as_deref() {
check_scopes(&authed, || format!("variables:write:{}", npath))?;
// It moves the resource linked at the same path too, which is how a
// permissioned data table finds its database.
if npath != path {
windmill_common::workspaces::ensure_resource_removal_allowed(&db, &w_id, path).await?;
}
check_proper_path(npath)?;
}
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
@@ -49,14 +49,17 @@
onSaved?: () => void
} = $props()
// The server refuses to enable permissions in a fork: its data table points
// either at the database of the workspace it was forked from, where the roles
// would be invisible to that workspace's own config, or at a copy the fork can
// drop. Turning them off stays available, so one that already has them can be
// rid of them.
// 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.
@@ -127,6 +130,8 @@
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 = {
@@ -263,7 +268,7 @@
<div class="flex flex-col gap-4">
<Toggle
bind:checked={enabled}
disabled={isFork && !enabled}
disabled={!editable || (isFork && !enabled)}
options={{
right: 'Enable permissions',
rightTooltip:
@@ -271,11 +276,16 @@
}}
/>
{#if isFork && !enabled}
{#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, where roles created
here would be invisible to its own configuration, or at a copy this fork can drop.
Enable permissions there instead, once this fork is deleted.
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}
@@ -382,7 +392,7 @@
<Button
variant="accent"
unifiedSize="md"
disabled={!hasUnsavedChanges || loading || !!loadError || !!nameError}
disabled={!editable || !hasUnsavedChanges || loading || !!loadError || !!nameError}
loading={saving}
on:click={requestPreview}
>
@@ -41,33 +41,11 @@
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.
let allDatatables = resource(
() => effectiveSource,
async (ws) => {
if (!ws) return undefined
const datatables = await WorkspaceService.listDataTables({ workspace: ws })
return await Promise.all(
datatables.map(async (dt) => {
try {
const roles = await WorkspaceService.listUsableDatatableRoles({
workspace: ws,
datatableName: dt.name
})
return { ...dt, permissioned: roles.enabled as boolean | undefined }
} catch (e) {
// Not `false`: what the fork does with this data table is decided by
// the config, and saying "kept" for one the backend will drop loses
// it silently.
console.error('Failed to read datatable permissions:', e)
return { ...dt, permissioned: undefined }
}
})
)
return await WorkspaceService.listDataTables({ workspace: ws })
}
)
@@ -214,30 +192,10 @@
(v) => (datatableBehaviors[dt.name] = v)
}
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'
},
// 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
? [
{ value: 'schema_only', label: 'Clone schema only' },
...(!isCloudHosted() && $userStore?.is_admin
? [{ value: 'schema_and_data', label: 'Clone schema and data' }]
: [])
]
{ value: 'keep_original', label: 'Keep original' },
{ value: 'schema_only', label: 'Clone schema only' },
...(!isCloudHosted() && $userStore?.is_admin
? [{ value: 'schema_and_data', label: 'Clone schema and data' }]
: [])
]}
/>