mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
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:
-23
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
-30
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
-47
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
-14
@@ -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 @@
|
||||
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
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' }]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user