mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-18 00:02:27 +00:00
Compare commits
130
Commits
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT 1 AS one FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "one",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "06abbf945bee93349ff88f64906b96ea1e853ef202510281427cfa9beeff81b3"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE EXISTS (\n SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d\n WHERE d.value->'reference'->>'workspace_id' = $1\n AND d.value->'reference'->>'datatable' = $2\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "06ce02cd7ce2f5a57355153edb573c242f9ba758db66e9a5e16f30e3e1494201"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT permissioned_as, permissioned_as_email FROM v2_job\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "permissioned_as",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "permissioned_as_email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0d10e0fa5cf4033c7d93c9ed56be8209046007917f44da954eccf2188e5bff1f"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $2\n THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND ws.datatable::text LIKE '%\"reference\"%'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "297c7a40dfce729d44aa37bc7c65560517bd25e40c0752a00467829191e2eb98"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND trigger_kind = 'postgres'\n AND (trigger_config->>'postgres_resource_path' = $2\n OR trigger_config->>'postgres_resource_path' LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2a391cc1bfcd2f75b46144a394c01237e09c3060da88170f1f6e06468309d213"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Name"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "334dbcd48fb59c96c62c2705ab2d1ce716cd52417f487cc1a8dd376017b2db7d"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1\n AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4692810d2be817bbb5de9b476d68d695941bd4fb5ccef393e4da522ed479d601"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5048e21546f9710697100100e1255ab103979433bc386d7c89d0e30db12bfd57"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "538dd1779874e4003932d7f17750239c625f85e25b3364bf2edf566f518c8ee2"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, name, enabled, pwd FROM datatable_role",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "pwd",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $2\n ORDER BY ws.workspace_id, dt.key\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "975e404ac3a6258bb8220e122e3de094c7ab23330fdbc74d6e4ad472ddd3c820"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext('datatable_role_catalog'))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9f663180166f53d117e794f3f3a5723a0a43db163ecca7d5a63d4e74ab1d3be1"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND trigger_kind = 'postgres'\n AND (trigger_config->>'postgres_resource_path' = $2\n OR trigger_config->>'postgres_resource_path' LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a57de2bb0442a5ee8a607cd63cfcf675de175796184f620cb4b09670c8b0b19f"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.datatable->'datatables' FROM workspace_settings ws\n WHERE ws.workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b42af37fb474bea4c5419b0a46d9eadfe384013ab970ccf9c5effd1c78321b7c"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT jsonb_object_keys(value->'databases') FROM global_settings\n WHERE name = 'custom_instance_pg_databases'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jsonb_object_keys",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM usr WHERE email = $1 RETURNING username, workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c1d026c886799dabc39ce73e1fe09ccb175c7271df75d67aa9c72ad6f825a992"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n ORDER BY ws.workspace_id, dt.key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c5451ea9d9fa5146af242d1ee8c19ebd65b80e7ed9f29b9fb2e03767c2aa94ba"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM datatable_role WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c85d362fe2e652d4ac01a35bf470e80b993020a2ff5dcb5849dc570d52798587"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id = $1\n AND dt.key <> $2\n AND NOT dt.value ? 'permissions'\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $3\n ORDER BY dt.key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d5fb5dde6300862f978739a3d9249fc2b3e7697c0da7d3195398933d3d81aadf"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND ws.datatable::text LIKE '%\"reference\"%'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "da5057c86607327bafc2942c218025ca9181a0c396405984d87e422e129521c1"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value->'roles' FROM global_settings WHERE name = 'custom_instance_pg_databases'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "dc8dfc37559e9b6713bde48155f48b5a2c7b8199eace1508e102b60d1ff40c04"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1\n AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e159b2ff15633f85e839ee4fe1ec2ecd11caf228ea8d0f52ad66def595644250"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(\n jsonb_set(\n datatable #- ARRAY['datatables', $2, 'reference'],\n ARRAY['datatables', $2, 'database'], $3::jsonb),\n ARRAY['datatables', $2, 'forked_from'], $4::jsonb\n )\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ebaf3ed3097621da59dd201b5a4b9d1f440692f183c7c378f59e4b73f1c6e241"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id FROM workspace_settings WHERE datatable::text LIKE $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f3ee09fb17955ca8d886f446d397063c4094546a7807343b570b823796372cef"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = CASE WHEN $3::jsonb = 'null'::jsonb\n THEN datatable #- ARRAY['datatables', $2, 'permissions']\n ELSE jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3::jsonb)\n END\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f4adc9e26ebfebce18a29fb2c21bf06394cacb8a9699a608327b097e0ac1363e"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE datatable_role SET name = $2, enabled = $3, pwd = $4 WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fcb34e643b888122766e115a01394ab31ac856252aaa76c75ea27a447009c363"
|
||||
}
|
||||
Generated
+1
@@ -15470,6 +15470,7 @@ dependencies = [
|
||||
"windmill-ai",
|
||||
"windmill-alerting",
|
||||
"windmill-api-auth",
|
||||
"windmill-audit",
|
||||
"windmill-common",
|
||||
"windmill-object-store",
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
d252afcc80e77fcc4f9a2a346b80908c8605a6c0
|
||||
c2e43f5b5ff753d339b70e232fd17b6ffecb054d
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Refuse while the catalog holds anything. Each row is a live Postgres login with a password
|
||||
-- only this table carries, so dropping it would leave credentials on the cluster that Windmill can
|
||||
-- no longer disable, delete or even name — and re-applying could not recreate them, because the
|
||||
-- role names would already be taken. Cleaning them up here is not an option either: dropping a
|
||||
-- role means reassigning what it owns in *every* instance database, and a migration runs in one.
|
||||
--
|
||||
-- Delete the roles through instance settings first; that path does the cluster work.
|
||||
LOCK TABLE datatable_role IN ACCESS EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM datatable_role) THEN
|
||||
RAISE EXCEPTION 'Cannot roll back: % data table role(s) still exist as Postgres logins. Delete them in instance settings first, which drops them from the cluster.',
|
||||
(SELECT count(*) FROM datatable_role);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP TABLE IF EXISTS datatable_role;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- The instance's data table role catalog: one row per Postgres login Windmill created for data
|
||||
-- table access.
|
||||
--
|
||||
-- A table rather than a `global_settings` key, because the value is a set of live cluster
|
||||
-- credentials and that table has generic read, list, write and CLI round-trip paths that know
|
||||
-- nothing about what they are carrying. Every one of them is a way to leak the passwords or to
|
||||
-- overwrite the catalog with a copy that has none, and a row nothing generic touches has none of
|
||||
-- those. One row per role also makes two concurrent creates two inserts rather than a
|
||||
-- read-modify-write over one document.
|
||||
CREATE TABLE datatable_role (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
-- The Postgres role name, verbatim. Unique because it is the cluster's own key.
|
||||
name VARCHAR(63) NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
-- Generated by Windmill, never entered by anyone, and never leaves the server.
|
||||
pwd TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
GRANT ALL ON datatable_role TO windmill_user;
|
||||
GRANT ALL ON datatable_role TO windmill_admin;
|
||||
@@ -730,7 +730,12 @@ pub fn parse_asset_syntax(
|
||||
s: &str,
|
||||
enable_default_syntax: bool,
|
||||
) -> Option<(AssetKind, Cow<'_, str>)> {
|
||||
if enable_default_syntax && s == "datatable" {
|
||||
// `datatable` and `datatable?role=analyst` both name the default data table: the role picks
|
||||
// which Postgres login the connection is made as, not which data table is read.
|
||||
if enable_default_syntax
|
||||
&& s.strip_prefix("datatable")
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('?'))
|
||||
{
|
||||
return Some((AssetKind::DataTable, Cow::Borrowed("main")));
|
||||
} else if enable_default_syntax && s == "ducklake" {
|
||||
return Some((AssetKind::Ducklake, Cow::Borrowed("main")));
|
||||
@@ -741,6 +746,14 @@ pub fn parse_asset_syntax(
|
||||
if *kind == AssetKind::Dbt {
|
||||
return Some((*kind, Cow::Owned(canonicalize_table_asset_path(suffix))));
|
||||
}
|
||||
// Same reasoning as above, for the explicit form. Specific to data tables: a
|
||||
// `Resource`'s `?table=` is part of what it names, and stripping it would merge two
|
||||
// different assets.
|
||||
if *kind == AssetKind::DataTable {
|
||||
if let Some((path, _role)) = suffix.split_once('?') {
|
||||
return Some((*kind, Cow::Borrowed(path)));
|
||||
}
|
||||
}
|
||||
// The suffix is kept verbatim. For S3 the path encodes the storage:
|
||||
// `s3://<storage>/<key>`, with an EMPTY storage segment for the
|
||||
// workspace default — so `s3:///key` yields `/key` (leading slash
|
||||
@@ -1692,6 +1705,25 @@ fn parse_trigger_spec(s: &str) -> Option<TriggerSpec> {
|
||||
mod pipeline_annotation_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_datatable_role_is_not_part_of_the_asset_it_names() {
|
||||
// The role picks which Postgres login the connection is made as, so two references that
|
||||
// differ only by role are the same asset and must land on one graph node.
|
||||
assert_eq!(
|
||||
parse_asset_syntax("datatable://sales?role=analytics", false),
|
||||
Some((AssetKind::DataTable, Cow::Borrowed("sales")))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_asset_syntax("datatable?role=analytics", true),
|
||||
Some((AssetKind::DataTable, Cow::Borrowed("main")))
|
||||
);
|
||||
// A resource's `?table=` is part of what it names, so it is kept.
|
||||
assert_eq!(
|
||||
parse_asset_syntax("$res:f/db/pg?table=users", false),
|
||||
Some((AssetKind::Resource, Cow::Borrowed("f/db/pg?table=users")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_path_keeps_storage_distinction() {
|
||||
// An S3 asset path is `<storage>/<key>` with an empty storage segment
|
||||
|
||||
@@ -800,6 +800,14 @@ async fn delete_folder(
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
|
||||
// See the same call in `delete_group`: a freed name must not stay in a tenant list.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&format!("f/{name}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let del = sqlx::query_scalar!(
|
||||
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1",
|
||||
name,
|
||||
|
||||
@@ -797,6 +797,15 @@ async fn delete_group(
|
||||
}
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
|
||||
// A tenant list names a principal, so a freed name must not linger in one: a later group
|
||||
// reusing it would silently inherit the data table access this one had.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&format!("g/{name}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2",
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Who may read and change a data table's grants and owners. On the Enterprise Edition: its
|
||||
//! administrators, from the workspace that governs it. Without it: nobody. Each refusal is decided
|
||||
//! before anything connects to the data table, so the fixture's database never has to exist.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn grant_select_on_public() -> Value {
|
||||
json!({
|
||||
"target": {"kind": "schema", "schema": "public"},
|
||||
"change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"],
|
||||
"scope": "all_tables"},
|
||||
"statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#]
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_acl(
|
||||
port: u16,
|
||||
w_id: &str,
|
||||
action: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<reqwest::Response> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&grant_select_on_public())
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A fork reaches the data table through a pointer: it may use it, never change what each role may
|
||||
/// touch on it — not even as an admin of the fork.
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_fork_cannot_change_access_on_the_data_table_it_points_at(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
for action in ["plan", "apply"] {
|
||||
let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_member_who_is_not_an_admin_cannot_change_access(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
for action in ["plan", "apply"] {
|
||||
let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Not even reading, and not even on a data table that is not under roles — which any member
|
||||
/// reaches, so only the edition stands between them and the instance's credentials.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn only_the_enterprise_edition_has_the_access_editor(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = datatable #- '{datatables,main,permissions}'
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let read = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/datatable_acl/main?kind=database"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
let mut responses = vec![("read", read)];
|
||||
for action in ["plan", "apply"] {
|
||||
responses.push((
|
||||
action,
|
||||
post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?,
|
||||
));
|
||||
}
|
||||
for (action, resp) in responses {
|
||||
assert_eq!(resp.status(), 400, "{action}");
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("Data table roles are a Windmill Enterprise Edition feature"),
|
||||
"{action}: {body}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
-- A data table under roles in `test-workspace`, and a fork whose entry points at it rather than
|
||||
-- carrying a copy. `test-user-2` is a non-admin of the parent and an admin of the fork: the shape
|
||||
-- the pointer exists for.
|
||||
|
||||
-- Empty registry: role provisioning grants CONNECT on every database named here, and the data
|
||||
-- table's `dt_main` is a name in workspace settings, not a database that exists.
|
||||
INSERT INTO global_settings (name, value) VALUES
|
||||
('custom_instance_pg_databases', '{"user_pwd": "pw", "databases": {}}'::jsonb)
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value;
|
||||
|
||||
INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ('role1', 'analytics', true, 'pw');
|
||||
|
||||
UPDATE workspace_settings SET datatable = '{
|
||||
"datatables": {
|
||||
"main": {
|
||||
"database": {"resource_type": "instance", "resource_path": "dt_main"},
|
||||
"permissions": {
|
||||
"default_role": "role1",
|
||||
"roles": {
|
||||
"admin": {"tenants": []},
|
||||
"role1": {"tenants": ["u/test-user-2", "g/analysts", "f/finance"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}'::jsonb WHERE workspace_id = 'test-workspace';
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace', 'analysts', 'Analysts', '{}');
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES
|
||||
('test-workspace', 'finance', 'finance', '{}', '{}');
|
||||
|
||||
INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES
|
||||
('wm-fork-dt', 'fork of test-workspace', 'test2@windmill.dev', 'test-workspace');
|
||||
INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('wm-fork-dt', 'cloud', 'test-key');
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('wm-fork-dt', 'all', 'All users', '{}');
|
||||
INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
||||
('wm-fork-dt', 'test2@windmill.dev', 'test-user-2', true, 'Admin');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('wm-fork-dt', '{
|
||||
"datatables": {
|
||||
"main": {"reference": {"workspace_id": "test-workspace", "datatable": "main"}}
|
||||
}
|
||||
}'::jsonb);
|
||||
@@ -11,7 +11,7 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
enterprise = ["license"]
|
||||
private = ["windmill-common/private"]
|
||||
private = ["windmill-common/private", "windmill-audit/private"]
|
||||
parquet = ["windmill-common/parquet", "windmill-object-store/parquet"]
|
||||
license = ["dep:rsa"]
|
||||
|
||||
@@ -19,6 +19,7 @@ license = ["dep:rsa"]
|
||||
windmill-ai = { workspace = true, default-features = false }
|
||||
windmill-alerting.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-audit.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
axum.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the data table role catalog endpoints come from: the enterprise implementation, or a
|
||||
//! refusal. Roles are an Enterprise Edition feature; see `windmill_common::datatable_roles_oss`.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_roles_ee::{
|
||||
create_datatable_role, delete_datatable_role, list_datatable_roles, update_datatable_role,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
// The routes stay registered so the API has one shape; each answers after authentication, before
|
||||
// anything is read.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{
|
||||
datatable_roles_oss::datatable_roles_unavailable as unavailable, error::Result,
|
||||
};
|
||||
|
||||
pub(crate) async fn list_datatable_roles(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn create_datatable_role(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_datatable_role(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_datatable_role(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ mod audit_logs_s3;
|
||||
mod audit_logs_s3_backfill;
|
||||
#[cfg(feature = "parquet")]
|
||||
mod background_task;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod datatable_roles_ee;
|
||||
mod datatable_roles_oss;
|
||||
#[cfg(feature = "private")]
|
||||
mod ee;
|
||||
pub mod ee_oss;
|
||||
@@ -151,6 +154,16 @@ pub fn global_service() -> Router {
|
||||
"/list_custom_instance_pg_databases",
|
||||
post(list_custom_instance_pg_databases),
|
||||
)
|
||||
.route(
|
||||
"/datatable_roles",
|
||||
get(datatable_roles_oss::list_datatable_roles)
|
||||
.post(datatable_roles_oss::create_datatable_role),
|
||||
)
|
||||
.route(
|
||||
"/datatable_roles/{id}",
|
||||
post(datatable_roles_oss::update_datatable_role)
|
||||
.delete(datatable_roles_oss::delete_datatable_role),
|
||||
)
|
||||
.route(
|
||||
"/refresh_custom_instance_user_pwd",
|
||||
post(refresh_custom_instance_user_pwd),
|
||||
@@ -1634,6 +1647,8 @@ struct CustomInstanceDb {
|
||||
tag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
used_by_workspaces: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Serialize, Default)]
|
||||
@@ -1674,7 +1689,15 @@ async fn list_custom_instance_pg_databases(
|
||||
))
|
||||
})?;
|
||||
|
||||
if windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
// Which workspace reserved a fork copy is nobody else's business: it would enumerate every
|
||||
// pending fork on the instance.
|
||||
for entry in result.values_mut() {
|
||||
entry.workspace_id = None;
|
||||
}
|
||||
return Ok(Json(result));
|
||||
}
|
||||
{
|
||||
// Enrich each database with the list of workspaces referencing it through
|
||||
// either a ducklake catalog or a datatable database whose resource_type is
|
||||
// 'instance'. Not stored in DB to avoid drift.
|
||||
@@ -1741,12 +1764,31 @@ async fn setup_custom_instance_pg_database(
|
||||
Path(dbname): Path<String>,
|
||||
Json(body): Json<SetupCustomInstanceDbBody>,
|
||||
) -> JsonResult<CustomInstanceDb> {
|
||||
// Before anything is recorded: the status written below replaces the registry entry, and with it
|
||||
// the workspace a fork copy is reserved for.
|
||||
require_super_admin(&db, &authed).await?;
|
||||
// A re-run keeps the fork reservation: without it, the workspace the copy was made for could no
|
||||
// longer import into it or finish its fork.
|
||||
let workspace_id = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT value->'databases'->$1->>'workspace_id' FROM global_settings
|
||||
WHERE name = 'custom_instance_pg_databases'",
|
||||
)
|
||||
.bind(&dbname)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let mut logs = CustomInstanceDbLogs::default();
|
||||
let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await;
|
||||
let success = result.is_ok();
|
||||
let error = result.err().map(|e| e.to_string());
|
||||
let status =
|
||||
CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] };
|
||||
let status = CustomInstanceDb {
|
||||
logs,
|
||||
success,
|
||||
error,
|
||||
tag: body.tag,
|
||||
used_by_workspaces: vec![],
|
||||
workspace_id,
|
||||
};
|
||||
let status_json = serde_json::to_value(&status).map_err(to_anyhow)?;
|
||||
// Save that the database was setup successfully
|
||||
sqlx::query!(
|
||||
|
||||
@@ -1703,14 +1703,25 @@ async fn delete_user(
|
||||
.await?;
|
||||
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?;
|
||||
|
||||
let usernames = sqlx::query_scalar!(
|
||||
"DELETE FROM usr WHERE email = $1 RETURNING username",
|
||||
let memberships = sqlx::query!(
|
||||
"DELETE FROM usr WHERE email = $1 RETURNING username, workspace_id",
|
||||
&email_to_delete
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for username in usernames {
|
||||
for row in memberships {
|
||||
let username = row.username;
|
||||
// A tenant list names a principal of its workspace, so the name has to be freed in every
|
||||
// workspace this account belonged to: a later account taking the username would otherwise
|
||||
// inherit the data table access it had.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&row.workspace_id,
|
||||
&format!("u/{username}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -2456,6 +2467,15 @@ pub async fn delete_workspace_user_internal(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
authed: Option<&ApiAuthed>, // None for system operations
|
||||
) -> Result<()> {
|
||||
// Same reasoning as the `extra_perms` sweep below: a freed username must not stay named
|
||||
// anywhere that grants access, tenant lists included.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
tx,
|
||||
w_id,
|
||||
&format!("u/{username_to_delete}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- Clean up extra_perms referencing this user ----
|
||||
let extra_perms_tables = [
|
||||
"script",
|
||||
@@ -3965,6 +3985,12 @@ async fn leave_workspace(
|
||||
) -> Result<String> {
|
||||
forbid_job_token_account_destruction(&authed)?;
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&format!("u/{}", authed.username),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
|
||||
&w_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the ACL planner comes from: the enterprise one, or a refusal.
|
||||
//!
|
||||
//! Data table roles are an Enterprise Edition feature, and so is everything here — reading who
|
||||
//! owns what included. `private` alone is not that edition — community builds carry it — so the
|
||||
//! planner is behind `enterprise` as well.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_acl_ee::plan_statements;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) fn ensure_datatable_acl_available() -> windmill_common::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
use {
|
||||
crate::datatable_acl::{AclChange, AclPlan, AclTarget, CatalogFacts},
|
||||
windmill_common::{datatable_roles_oss::datatable_roles_unavailable, error::Result},
|
||||
};
|
||||
|
||||
/// Checked first by every ACL route, before anything is read or connected to.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn ensure_datatable_acl_available() -> Result<()> {
|
||||
Err(datatable_roles_unavailable())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn plan_statements(
|
||||
_target: &AclTarget,
|
||||
_change: &AclChange,
|
||||
_dbname: &str,
|
||||
_pg_role: &str,
|
||||
_facts: &CatalogFacts,
|
||||
) -> Result<AclPlan> {
|
||||
Err(datatable_roles_unavailable())
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use windmill_api_auth::{require_super_admin, ApiAuthed};
|
||||
use windmill_api_jobs::run_wait_result_internal;
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
|
||||
use windmill_common::jobs::{JobPayload, RawCode};
|
||||
@@ -38,7 +39,11 @@ use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, Debounci
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||||
use windmill_common::worker::SqlAnnotations;
|
||||
use windmill_common::workspaces::{
|
||||
ensure_can_use_datatable_role, ensure_datatable_admin_access,
|
||||
get_datatable_resource_from_db_unchecked, resolve_governing_datatable, DatatableAccess,
|
||||
};
|
||||
use windmill_common::{PgDatabase, DB};
|
||||
use windmill_git_sync::{
|
||||
handle_deployment_metadata, handle_deployment_metadata_batch, DeployedObject,
|
||||
@@ -86,6 +91,42 @@ pub(crate) fn routes() -> Router {
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse a migration whose role this caller may not use, before a job is pushed or a version
|
||||
/// recorded.
|
||||
///
|
||||
/// A migration that declares `-- role <name>` runs as that role, so the caller has to be one of its
|
||||
/// tenants. One that declares none runs as `admin` and reaches every object in the database
|
||||
/// whatever the roles grant, so it is for the admins of the workspace that governs the data table
|
||||
/// — a fork can run a migration under a role it holds, never a migration under `admin`.
|
||||
///
|
||||
/// The executor re-checks the role when it resolves the connection, so this is not the boundary. It
|
||||
/// is what makes the refusal legible: which migration, and which role.
|
||||
async fn ensure_migration_role_allowed(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
sql: &str,
|
||||
timestamp: i64,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
let context = format!("Migration {timestamp} ({name})");
|
||||
let access = DatatableAccess::Authed(authed.to_authed_ref());
|
||||
match SqlAnnotations::datatable_role(sql)? {
|
||||
Some(role) => {
|
||||
ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, &context)
|
||||
.await
|
||||
}
|
||||
None => ensure_datatable_admin_access(db, w_id, datatable_name, &access)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::NotAuthorized(format!(
|
||||
"{context} declares no role, so it would run as admin. {e}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppliedMigration {
|
||||
version: i64,
|
||||
@@ -128,7 +169,18 @@ async fn datatable_database_arg(
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?;
|
||||
|
||||
Ok(to_raw_value(&format!("datatable://{datatable_name}")))
|
||||
// `?role=admin` rather than a bare reference, so a migration that declares no `-- role` runs
|
||||
// as the connection that owns the schema instead of falling through to the data table's
|
||||
// default role — which is what `ensure_migration_role_allowed` gated it as, and which is the
|
||||
// only role a DDL statement can be expected to succeed under. A migration that does declare a
|
||||
// role overrides this: the annotation wins over the reference.
|
||||
//
|
||||
// A legacy name containing `?` cannot be migrated through this reference: the appended query
|
||||
// makes it neither an exact name nor a parseable one. Accepted on purpose, since such names can
|
||||
// no longer be created and none are expected to carry migrations.
|
||||
Ok(to_raw_value(&format!(
|
||||
"datatable://{datatable_name}?role={ADMIN_DATATABLE_ROLE}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as
|
||||
@@ -384,6 +436,11 @@ async fn run_datatable_migrations(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Query(query): Query<RunDatatableMigrationsQuery>,
|
||||
) -> JsonResult<RunDatatableMigrationsResult> {
|
||||
// Before the admin connection is opened at all: the bookkeeping below is created and read
|
||||
// through it, so a caller no role covers must be refused here rather than after the fact.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
@@ -440,6 +497,16 @@ async fn run_datatable_migrations(
|
||||
if applied_versions.contains(&m.timestamp) {
|
||||
continue;
|
||||
}
|
||||
ensure_migration_role_allowed(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&m.code_up,
|
||||
m.timestamp,
|
||||
&m.name,
|
||||
)
|
||||
.await?;
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -506,6 +573,11 @@ async fn rollback_datatable_migrations(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Query(query): Query<RollbackDatatableMigrationsQuery>,
|
||||
) -> JsonResult<RollbackDatatableMigrationsResult> {
|
||||
// Before the admin connection is opened at all: the bookkeeping below is created and read
|
||||
// through it, so a caller no role covers must be refused here rather than after the fact.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
@@ -588,6 +660,17 @@ async fn rollback_datatable_migrations(
|
||||
))
|
||||
})?;
|
||||
|
||||
ensure_migration_role_allowed(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&code_down,
|
||||
version,
|
||||
&definition.name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?;
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &code_down)
|
||||
.await
|
||||
@@ -748,10 +831,15 @@ async fn read_applied_datatable_versions(
|
||||
|
||||
/// List a data table's migrations annotated with whether each has been applied.
|
||||
async fn datatable_migrations_status(
|
||||
_authed: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<DatatableMigrationsStatusResult> {
|
||||
// Reads `_wm_migrations` through the data table's admin connection, so it answers to the same
|
||||
// question as running one: may you reach this data table at all.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
.await?;
|
||||
|
||||
let enabled = datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
if !enabled {
|
||||
return Ok(Json(DatatableMigrationsStatusResult {
|
||||
@@ -1431,6 +1519,15 @@ async fn generate_initial_datatable_migration(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<DatatableMigration> {
|
||||
// Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so
|
||||
// it answers to the workspace that governs it rather than to whoever is asking.
|
||||
ensure_datatable_admin_access(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
validate_datatable_path_segment(&datatable_name)?;
|
||||
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
|
||||
@@ -1601,9 +1698,21 @@ pub(crate) struct DatatableRename {
|
||||
pub(crate) to: String,
|
||||
}
|
||||
|
||||
async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result<PgDatabase> {
|
||||
/// The database whose `_wm_migrations` a rename or delete of `datatable` in `w_id` should touch —
|
||||
/// `None` when that is somebody else's.
|
||||
///
|
||||
/// A fork's entry points at the workspace that governs the data table, so renaming or removing it
|
||||
/// changes what the fork calls the data table and nothing more. Following the pointer here would
|
||||
/// let a fork admin relabel or wipe the *governing* workspace's migration bookkeeping through
|
||||
/// their own settings form, and the parent would then re-run every migration from zero.
|
||||
async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result<Option<PgDatabase>> {
|
||||
let governing = resolve_governing_datatable(db, w_id, datatable).await?;
|
||||
if governing.workspace_id != w_id {
|
||||
return Ok(None);
|
||||
}
|
||||
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable).await?;
|
||||
serde_json::from_value(db_resource)
|
||||
.map(Some)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
|
||||
}
|
||||
|
||||
@@ -1621,7 +1730,9 @@ fn ignore_missing_wm_migrations(e: tokio_postgres::Error) -> Result<()> {
|
||||
|
||||
/// Drop a data table's rows from its own database's `_wm_migrations`.
|
||||
async fn remote_forget_datatable_migrations(db: &DB, w_id: &str, datatable: &str) -> Result<()> {
|
||||
let pg_db = resolve_datatable_pg(db, w_id, datatable).await?;
|
||||
let Some(pg_db) = resolve_datatable_pg(db, w_id, datatable).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
@@ -1646,7 +1757,9 @@ async fn remote_rename_datatable_migrations(
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<()> {
|
||||
let pg_db = resolve_datatable_pg(db, w_id, resolve_by).await?;
|
||||
let Some(pg_db) = resolve_datatable_pg(db, w_id, resolve_by).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Who may connect to a data table as which role.
|
||||
//!
|
||||
//! The decision lives on the data table entry of the workspace that governs it, which is not
|
||||
//! necessarily the workspace asking: a fork's entry points at its parent's, and everything here
|
||||
//! resolves through that pointer first. Nothing in this module runs SQL against the data table —
|
||||
//! a save is tenant lists and a default, and the Postgres roles themselves are the instance
|
||||
//! catalog's business.
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::error::Result;
|
||||
use windmill_common::workspaces::GoverningDatatable;
|
||||
use windmill_common::DB;
|
||||
|
||||
use crate::datatable_permissions_oss as roles;
|
||||
|
||||
pub(crate) fn routes() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/datatable_permissions/{datatable_name}",
|
||||
get(roles::get_datatable_permissions).post(roles::set_datatable_permissions),
|
||||
)
|
||||
.route(
|
||||
"/datatable_usable_roles/{datatable_name}",
|
||||
get(roles::list_usable_datatable_roles),
|
||||
)
|
||||
}
|
||||
|
||||
/// Administering a data table — its permissions, its migrations that declare no role, its exports
|
||||
/// — is for the admins of the workspace that governs it. A fork can use the data table; it never
|
||||
/// administers it.
|
||||
// The gate for whatever administers a data table under roles, which the routes of this module alone
|
||||
// do not always reach.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn ensure_governs_datatable(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
governing: &GoverningDatatable,
|
||||
) -> Result<()> {
|
||||
roles::ensure_governs_datatable(db, authed, w_id, governing).await
|
||||
}
|
||||
|
||||
/// Refuse a caller that no tenant of this data table covers.
|
||||
///
|
||||
/// The bookkeeping endpoints below open the data table's `admin` connection to read or create
|
||||
/// `_wm_migrations` before they know which migration will run — so without this, someone covered
|
||||
/// by no role at all can still force admin-backed reads and writes on a database they may not
|
||||
/// touch. It asks only "may you reach this data table as anything"; which role a given migration
|
||||
/// runs as is still decided per migration, and by the executor after that.
|
||||
pub(crate) async fn ensure_reaches_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
roles::ensure_reaches_datatable(db, w_id, datatable_name, authed).await
|
||||
}
|
||||
|
||||
/// [`ensure_reaches_datatable`] against an entry already resolved, for a caller that goes on to
|
||||
/// connect from that same entry.
|
||||
pub(crate) async fn ensure_reaches_governing_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
governing: &GoverningDatatable,
|
||||
authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
roles::ensure_reaches_governing_datatable(db, w_id, datatable_name, governing, authed).await
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the data table permissions endpoints and their gates come from: the enterprise
|
||||
//! implementation, or a refusal. Roles are an Enterprise Edition feature; see
|
||||
//! `windmill_common::datatable_roles_oss`.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_permissions_ee::{
|
||||
ensure_governs_datatable, ensure_reaches_datatable, ensure_reaches_governing_datatable,
|
||||
get_datatable_permissions, list_usable_datatable_roles, set_datatable_permissions,
|
||||
usable_datatable_roles,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{
|
||||
datatable_roles_oss::datatable_roles_unavailable as unavailable,
|
||||
error::Result,
|
||||
workspaces::{resolve_governing_datatable, GoverningDatatable},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// Nobody administers a data table's roles without them.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn ensure_governs_datatable(
|
||||
_db: &DB,
|
||||
_authed: &ApiAuthed,
|
||||
_w_id: &str,
|
||||
_governing: &GoverningDatatable,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// A data table not under roles is reached as it was before roles existed. One under roles is
|
||||
/// refused: no role of it can be connected as.
|
||||
pub(crate) async fn ensure_reaches_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
_authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
let governing = resolve_governing_datatable(db, w_id, datatable_name).await?;
|
||||
if governing.datatable.permissions.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_reaches_governing_datatable(
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_datatable_name: &str,
|
||||
governing: &GoverningDatatable,
|
||||
_authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
if governing.datatable.permissions.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
// The routes stay registered so the API has one shape; each answers after authentication,
|
||||
// before anything is read.
|
||||
|
||||
pub(crate) async fn get_datatable_permissions(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_datatable_permissions(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) struct UsableDatatableRoles {
|
||||
pub(crate) permissioned: bool,
|
||||
pub(crate) roles: Vec<String>,
|
||||
pub(crate) default_role: String,
|
||||
}
|
||||
|
||||
/// A data table not under roles is used as `admin`, as before roles existed. One under roles
|
||||
/// is refused: no role of it can be connected as.
|
||||
pub(crate) async fn usable_datatable_roles(
|
||||
_db: &DB,
|
||||
_authed: &ApiAuthed,
|
||||
_w_id: &str,
|
||||
governing: &GoverningDatatable,
|
||||
) -> Result<UsableDatatableRoles> {
|
||||
if governing.datatable.permissions.is_some() {
|
||||
return Err(unavailable());
|
||||
}
|
||||
Ok(UsableDatatableRoles {
|
||||
permissioned: false,
|
||||
roles: vec![],
|
||||
default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
#[cfg(feature = "parquet")]
|
||||
pub mod ai_session_backups;
|
||||
pub mod data_metrics;
|
||||
pub mod datatable_acl;
|
||||
pub mod datatable_acl_oss;
|
||||
pub mod datatable_migrations;
|
||||
pub mod datatable_permissions;
|
||||
pub mod datatable_permissions_oss;
|
||||
pub mod deployment_requests;
|
||||
pub mod workspaces;
|
||||
pub mod workspaces_extra;
|
||||
@@ -9,3 +13,9 @@ pub mod workspaces_oss;
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
pub mod workspaces_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_acl_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_permissions_ee;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,21 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A fork copy reserved for the old id would otherwise be unreachable: its creator cannot
|
||||
// import into it or finish its fork under the new id, and nothing else would ever drop it.
|
||||
sqlx::query(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (
|
||||
SELECT COALESCE(jsonb_object_agg(k, CASE WHEN v->>'workspace_id' = $1
|
||||
THEN jsonb_set(v, '{workspace_id}', to_jsonb($2::text)) ELSE v END), '{}'::jsonb)
|
||||
FROM jsonb_each(COALESCE(value->'databases', '{}'::jsonb)) AS e(k, v)
|
||||
))
|
||||
WHERE name = 'custom_instance_pg_databases'"#,
|
||||
)
|
||||
.bind(&old_id)
|
||||
.bind(&rw.new_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
@@ -492,6 +507,30 @@ pub(crate) async fn change_workspace_id(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A fork's data table entry names the workspace that governs it by id, so the rename has to
|
||||
// follow there too — anywhere, not just in the reparented children: a detached workspace can
|
||||
// point at this one without being its fork. Left behind, the pointer resolves to the archived
|
||||
// shell and every job through it stops.
|
||||
info!("Re-pointing data table references to the new workspace id");
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings ws
|
||||
SET datatable = (
|
||||
SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(
|
||||
dt.key,
|
||||
CASE WHEN dt.value->'reference'->>'workspace_id' = $2
|
||||
THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))
|
||||
ELSE dt.value END
|
||||
))
|
||||
FROM jsonb_each(ws.datatable->'datatables') dt
|
||||
)
|
||||
WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
AND ws.datatable::text LIKE '%"reference"%'"#,
|
||||
&rw.new_id,
|
||||
&old_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_protection_rule table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
@@ -971,6 +1010,22 @@ pub(crate) async fn delete_workspace(
|
||||
// but the destructive cleanup itself runs only after the commit below: a delete that
|
||||
// fails mid-way must never leave a live workspace with its fork data destroyed and no
|
||||
// registry row to retry from. Read-only: nothing is dropped here.
|
||||
// Read before the delete: another workspace's data table entry can point at one of this
|
||||
// workspace's, and deleting the workspace it names leaves that pointer resolving to nothing.
|
||||
// Nothing sweeps them — turning them back into copies would hand each fork the database
|
||||
// outright — so the deleter is told which data tables they just stranded.
|
||||
let stranded_pointers = sqlx::query!(
|
||||
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!"
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||||
WHERE dt.value->'reference'->>'workspace_id' = $1
|
||||
ORDER BY ws.workspace_id, dt.key"#,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let fork_ducklake_cleanups = prepare_fork_ducklake_cleanups(&db, &w_id, None)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -1289,7 +1344,23 @@ pub(crate) async fn delete_workspace(
|
||||
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
|
||||
}
|
||||
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
if stranded_pointers.is_empty() {
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
} else {
|
||||
let stranded = stranded_pointers
|
||||
.iter()
|
||||
.map(|r| format!("{}/{}", r.workspace_id, r.datatable))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Ok(format!(
|
||||
concat!(
|
||||
"Deleted workspace {}. These data tables were governed by it and no longer ",
|
||||
"resolve: {}. Their databases still exist; a superadmin can point them at ",
|
||||
"another workspace's data table."
|
||||
),
|
||||
&w_id, stranded
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1343,15 +1414,20 @@ pub async fn drop_forked_datatable_databases(
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
for dt_name in &req.datatable_names {
|
||||
let dt = match datatables.get(dt_name) {
|
||||
Some(dt) if dt.forked_from.is_some() => dt,
|
||||
// Only a clone is droppable, and a clone is terminal by construction: a kept data table is
|
||||
// a pointer at the parent's database, which this fork does not own.
|
||||
let database = match datatables.get(dt_name) {
|
||||
Some(dt) if dt.forked_from.is_some() => match dt.database.as_ref() {
|
||||
Some(database) => database,
|
||||
None => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if dt.database.resource_type
|
||||
if database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
||||
{
|
||||
let db_to_drop = &dt.database.resource_path;
|
||||
let db_to_drop = &database.resource_path;
|
||||
if !db_to_drop.starts_with("wm_fork_") {
|
||||
errors.push(format!(
|
||||
"Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'",
|
||||
@@ -1359,7 +1435,53 @@ pub async fn drop_forked_datatable_databases(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
|
||||
// The fork's own entry is what is going away; anything else still reaching the copy,
|
||||
// a child fork's pointer at this entry included, keeps it. The lock keeps a child fork
|
||||
// from gaining such a pointer before the drop.
|
||||
let dropped = async {
|
||||
let mut tx = db.begin().await?;
|
||||
// The three locks a settings save takes, in its order: this workspace's data
|
||||
// tables, its settings row, and the database itself. Without them a save could
|
||||
// rename this entry, or point another one here, either side of the check below.
|
||||
windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?;
|
||||
sqlx::query("SELECT 1 FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE")
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::datatable_roles::lock_instance_databases_governance(
|
||||
&mut tx,
|
||||
[db_to_drop.as_str()],
|
||||
)
|
||||
.await?;
|
||||
let uses = windmill_common::workspaces::managed_database_uses(
|
||||
&mut tx,
|
||||
windmill_common::workspaces::DataTableCatalogResourceType::Instance,
|
||||
db_to_drop,
|
||||
Some((&w_id, dt_name)),
|
||||
)
|
||||
.await?;
|
||||
if !uses.is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"it is still used by {}",
|
||||
uses.join(", ")
|
||||
)));
|
||||
}
|
||||
// The entry goes with the database: a fork this one is cloned into afterwards must
|
||||
// not inherit a pointer at a data table whose database is gone.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2]
|
||||
WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(dt_name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::drop_custom_instance_database(&db, db_to_drop).await?;
|
||||
tx.commit().await?;
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = dropped {
|
||||
errors.push(format!(
|
||||
"Could not drop instance database '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
|
||||
@@ -1588,6 +1588,89 @@ paths:
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/CustomInstanceDb"
|
||||
|
||||
/settings/datatable_roles:
|
||||
get:
|
||||
summary: list the instance's data table roles
|
||||
operationId: listInstanceDatatableRoles
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: the instance role catalog
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
post:
|
||||
summary: create a data table role on the instance's Postgres cluster
|
||||
operationId: createInstanceDatatableRole
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the created role
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
|
||||
/settings/datatable_roles/{id}:
|
||||
post:
|
||||
summary: rename a data table role or turn its login on and off
|
||||
operationId: updateInstanceDatatableRole
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: the updated role
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
delete:
|
||||
summary: drop a data table role from the cluster and from every workspace that named it
|
||||
operationId: deleteInstanceDatatableRole
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: deleted
|
||||
|
||||
/settings/setup_custom_instance_pg_database/{name}:
|
||||
post:
|
||||
summary: Runs CREATE DATABASE on the Windmill Postgres and grants access to the custom_instance_user
|
||||
@@ -5141,7 +5224,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [name, resource_type, resource_path]
|
||||
required: [name, resource_type, resource_path, permissioned]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
@@ -5150,6 +5233,188 @@ paths:
|
||||
enum: [postgres, instance]
|
||||
resource_path:
|
||||
type: string
|
||||
governing_workspace_id:
|
||||
type: string
|
||||
permissioned:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}:
|
||||
get:
|
||||
summary: get who may connect to a data table as which role
|
||||
operationId: getDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the data table's roles and their tenants
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DatatablePermissions"
|
||||
post:
|
||||
summary: set who may connect to a data table as which role
|
||||
operationId: setDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [permissioned]
|
||||
properties:
|
||||
permissioned:
|
||||
type: boolean
|
||||
default_role:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DatatableRoleTenants"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}:
|
||||
get:
|
||||
summary: read the owner and grants of an instance data table's database, schema or table
|
||||
operationId: getDatatableAcl
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: kind
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [database, schema, table]
|
||||
- name: schema
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: table
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: owner and grants
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DatatableAclInfo"
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan:
|
||||
post:
|
||||
summary: preview the SQL an ownership or grant change would run (data table administrators only)
|
||||
operationId: planDatatableAcl
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AclChangeRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: statements that would run, in a single transaction
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AclPlan"
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply:
|
||||
post:
|
||||
summary: run an ownership or grant change exactly as planned (data table administrators only)
|
||||
operationId: applyDatatableAcl
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AclChangeRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: change applied
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
|
||||
get:
|
||||
summary: list the data table roles the caller may connect as
|
||||
operationId: listUsableDatatableRoles
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: usable roles
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [permissioned, roles, default_role]
|
||||
properties:
|
||||
permissioned:
|
||||
type: boolean
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/list_datatable_schemas:
|
||||
get:
|
||||
@@ -5223,6 +5488,21 @@ paths:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: query
|
||||
description: list only this data table; each listed data table opens a connection to its database
|
||||
schema:
|
||||
type: string
|
||||
- name: role_for
|
||||
in: query
|
||||
description: the data table `role` applies to; every other one is listed as its default role
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
description: the role to list `role_for` as; refused, in that entry's `error`, if the caller may not use it
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: table metadata of all datatables
|
||||
@@ -5256,6 +5536,11 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
description: the data table role to read the table as; defaults to the data table's default role
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schema of one datatable table
|
||||
@@ -5331,7 +5616,22 @@ paths:
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
stranded_references:
|
||||
description: >-
|
||||
Data tables in other workspaces that were governed by one this save deleted
|
||||
and no longer resolve.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/run_datatable_migrations/{datatable_name}:
|
||||
post:
|
||||
@@ -33321,6 +33621,315 @@ components:
|
||||
- ducklake
|
||||
- datatable
|
||||
|
||||
InstanceDatatableRole:
|
||||
type: object
|
||||
required: [id, name, enabled]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
|
||||
DatatableRoleTenants:
|
||||
type: object
|
||||
required: [id, tenants]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
tenants:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
DatatablePermissions:
|
||||
type: object
|
||||
required: [supported, permissioned, default_role, roles, editable, available_roles]
|
||||
properties:
|
||||
supported:
|
||||
type: boolean
|
||||
description: >-
|
||||
Whether this data table can be put under roles at all. Only one backed by the
|
||||
instance database can: a role is a login on that cluster.
|
||||
permissioned:
|
||||
type: boolean
|
||||
default_role:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DatatableRoleTenants"
|
||||
governing_workspace_id:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
available_roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
ungoverned_reachers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
|
||||
AclTarget:
|
||||
description: what access is read or changed on
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AclTargetDatabase"
|
||||
- $ref: "#/components/schemas/AclTargetSchema"
|
||||
- $ref: "#/components/schemas/AclTargetTable"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
database: "#/components/schemas/AclTargetDatabase"
|
||||
schema: "#/components/schemas/AclTargetSchema"
|
||||
table: "#/components/schemas/AclTargetTable"
|
||||
|
||||
AclTargetDatabase:
|
||||
type: object
|
||||
required: [kind]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database]
|
||||
|
||||
AclTargetSchema:
|
||||
type: object
|
||||
required: [kind, schema]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [schema]
|
||||
schema:
|
||||
type: string
|
||||
|
||||
AclTargetTable:
|
||||
type: object
|
||||
required: [kind, schema, table]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [table]
|
||||
schema:
|
||||
type: string
|
||||
table:
|
||||
type: string
|
||||
|
||||
AclChange:
|
||||
description: one change to plan or apply
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AclChangeSetOwner"
|
||||
- $ref: "#/components/schemas/AclChangeGrant"
|
||||
- $ref: "#/components/schemas/AclChangeRevoke"
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
set_owner: "#/components/schemas/AclChangeSetOwner"
|
||||
grant: "#/components/schemas/AclChangeGrant"
|
||||
revoke: "#/components/schemas/AclChangeRevoke"
|
||||
|
||||
AclChangeSetOwner:
|
||||
type: object
|
||||
description: >-
|
||||
hands the target to role — for a schema, with everything already in it but an extension's
|
||||
members, which stay with the extension
|
||||
required: [type, role]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [set_owner]
|
||||
role:
|
||||
type: string
|
||||
description: a data table role of the instance, or admin
|
||||
|
||||
AclChangeGrant:
|
||||
type: object
|
||||
required: [type, role, privileges, scope]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [grant]
|
||||
role:
|
||||
type: string
|
||||
description: a data table role of the instance, or admin
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
scope:
|
||||
$ref: "#/components/schemas/AclGrantScope"
|
||||
|
||||
AclChangeRevoke:
|
||||
type: object
|
||||
required: [type, role, privileges, scope]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [revoke]
|
||||
role:
|
||||
type: string
|
||||
description: a data table role of the instance, other than admin
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
scope:
|
||||
$ref: "#/components/schemas/AclGrantScope"
|
||||
objects:
|
||||
type: array
|
||||
description: >-
|
||||
objects inside the target the revoke covers, empty for the target itself. Only with the
|
||||
target scope; a revoke on all objects of a kind is refused, since it cannot say which
|
||||
grants it takes back.
|
||||
items:
|
||||
$ref: "#/components/schemas/AclObject"
|
||||
|
||||
AclGrantScope:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
target,
|
||||
all_tables,
|
||||
all_sequences,
|
||||
all_functions,
|
||||
future_tables,
|
||||
future_sequences,
|
||||
future_functions,
|
||||
]
|
||||
|
||||
AclChangeRequest:
|
||||
type: object
|
||||
required: [target, change]
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/AclTarget"
|
||||
change:
|
||||
$ref: "#/components/schemas/AclChange"
|
||||
statements:
|
||||
type: array
|
||||
description: >-
|
||||
The statements the plan showed. Required to apply, which plans again and refuses if
|
||||
the result differs.
|
||||
items:
|
||||
type: string
|
||||
|
||||
AclPlan:
|
||||
type: object
|
||||
required: [statements, warnings]
|
||||
properties:
|
||||
statements:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
AclObject:
|
||||
type: object
|
||||
required: [name, kind]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
description: >-
|
||||
TABLE, SEQUENCE, FUNCTION, PROCEDURE or TYPE — what the object is. A revoke turns it
|
||||
into the keyword it takes, ROUTINE for both routine kinds; a type's grants are read
|
||||
only.
|
||||
args:
|
||||
type: string
|
||||
description: identity arguments of a routine, which is what tells two of the same name apart
|
||||
|
||||
AclGrant:
|
||||
type: object
|
||||
required: [grantee, privileges, sources]
|
||||
properties:
|
||||
grantee:
|
||||
type: string
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
object:
|
||||
$ref: "#/components/schemas/AclObject"
|
||||
future:
|
||||
type: string
|
||||
description: >-
|
||||
set for a default privilege, naming the kind of object it covers (TABLES, SEQUENCES,
|
||||
FUNCTIONS, TYPES, or SCHEMAS). On a schema, the defaults set in that schema; on the
|
||||
database, the ones set database-wide, which apply in every schema and which no schema's
|
||||
own defaults take back.
|
||||
sources:
|
||||
type: array
|
||||
description: >-
|
||||
the roles the grant comes from, each once — who granted it, or for a default privilege
|
||||
the role whose future objects it covers. A revoke of some of the grant's privileges
|
||||
takes them back from every source that gave them.
|
||||
items:
|
||||
$ref: "#/components/schemas/AclSource"
|
||||
|
||||
AclSource:
|
||||
type: object
|
||||
required: [role, privileges, reachable]
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
privileges:
|
||||
type: array
|
||||
description: >-
|
||||
what role gave of the grant's privileges. A revoke is held back only by a source out of
|
||||
reach that gave some of what it takes back.
|
||||
items:
|
||||
type: string
|
||||
reachable:
|
||||
type: boolean
|
||||
description: >-
|
||||
whether the data table's connection can take back what role gave. On an object that is
|
||||
the owner, when the connection acts for the owner, and otherwise the connection itself;
|
||||
for a default privilege, a creating role the connection acts for. What a source out of
|
||||
reach gave is not revocable from here; privileges only other sources gave still are.
|
||||
|
||||
DatatableAclInfo:
|
||||
type: object
|
||||
required: [owner, roles, editable, supports_maintain, dbname, grants, children]
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
description: the roles a change may name; empty unless the caller may change anything
|
||||
items:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
description: whether the caller may plan and apply changes
|
||||
supports_maintain:
|
||||
type: boolean
|
||||
description: whether the server is Postgres 17+, which added the MAINTAIN table privilege
|
||||
dbname:
|
||||
type: string
|
||||
description: the database the target lives in
|
||||
grants:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AclGrant"
|
||||
children:
|
||||
type: array
|
||||
description: a database's schemas, or a schema's tables
|
||||
items:
|
||||
type: string
|
||||
|
||||
CustomInstanceDb:
|
||||
type: object
|
||||
required:
|
||||
@@ -33345,6 +33954,9 @@ components:
|
||||
items:
|
||||
type: string
|
||||
description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted.
|
||||
workspace_id:
|
||||
type: string
|
||||
description: The workspace a member created this database for as a fork copy. Only that workspace can import into it or point a fork at it.
|
||||
|
||||
NewSqsTrigger:
|
||||
type: object
|
||||
@@ -35369,9 +35981,11 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
required: [database]
|
||||
properties:
|
||||
database:
|
||||
description: >-
|
||||
Set on an entry that owns its database. Absent on a fork's entry, which points at
|
||||
another workspace's data table instead.
|
||||
type: object
|
||||
properties:
|
||||
resource_type:
|
||||
@@ -35383,6 +35997,17 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- resource_type
|
||||
reference:
|
||||
description: >-
|
||||
The workspace and data table that govern this one. Server-owned: written by fork
|
||||
creation, and carried across a settings save whatever the request says.
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
migrations_enabled:
|
||||
type: boolean
|
||||
description: Whether the SQL migrations feature is opted in for this data table
|
||||
@@ -35451,7 +36076,17 @@ components:
|
||||
|
||||
DataTableTables:
|
||||
type: object
|
||||
required: [datatable_name, schemas]
|
||||
required:
|
||||
[
|
||||
datatable_name,
|
||||
schemas,
|
||||
instance,
|
||||
permissioned,
|
||||
usable_roles,
|
||||
default_role,
|
||||
can_create_schema,
|
||||
creatable_schemas,
|
||||
]
|
||||
properties:
|
||||
datatable_name:
|
||||
type: string
|
||||
@@ -35464,6 +36099,26 @@ components:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
instance:
|
||||
type: boolean
|
||||
description: on the instance database, the only kind that can be under roles or have its access edited
|
||||
permissioned:
|
||||
type: boolean
|
||||
usable_roles:
|
||||
type: array
|
||||
description: the roles the caller may connect as, by name; empty when not under roles
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
can_create_schema:
|
||||
type: boolean
|
||||
description: whether the role the listing connected as may create schemas
|
||||
creatable_schemas:
|
||||
type: array
|
||||
description: the schemas the role the listing connected as may create in
|
||||
items:
|
||||
type: string
|
||||
|
||||
DataTableTableSchema:
|
||||
type: object
|
||||
|
||||
@@ -567,6 +567,9 @@ async fn set_config(
|
||||
};
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
if matches!(nc.trigger_kind, TriggerKind::Postgres) {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -614,6 +617,9 @@ async fn ping_config(
|
||||
)>,
|
||||
) -> Result<()> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
if matches!(trigger_kind, TriggerKind::Postgres) {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
|
||||
@@ -8343,8 +8343,9 @@ pub async fn run_wait_result_flow_by_version(
|
||||
/// job lives, in particular DuckDB, which runs in-process in the worker.
|
||||
///
|
||||
/// What it does permit is any statement against the workspace's data tables, writes and DDL
|
||||
/// included: the helper's body is an unrestricted SQL template and data tables carry no
|
||||
/// per-user ACL. Narrowing that is a separate decision from this exemption.
|
||||
/// included: the helper's body is an unrestricted SQL template. What that reaches is the
|
||||
/// operator's own data table role — the preview job is permissioned as them, so the executor
|
||||
/// resolves it under their tenancy like any other job.
|
||||
///
|
||||
/// The database argument is only half the target: the executor honors a `-- database`
|
||||
/// directive in the SQL over it, and `-- s3` redirects the result set, so both are refused.
|
||||
@@ -8576,7 +8577,7 @@ async fn run_inline_preview_script(
|
||||
#[cfg(not(feature = "run_inline"))]
|
||||
async fn run_inline_preview_script() -> error::Result<Response> {
|
||||
Err(error::Error::InternalErr(
|
||||
"inline preview requires the worker feature".to_string(),
|
||||
"inline preview requires the run_inline feature on the worker".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -8700,7 +8701,12 @@ fn register_potential_assets_on_inline_execution(
|
||||
.as_ref()
|
||||
.and_then(|args| args.get("database"))
|
||||
.map(|v| v.get().trim_matches('"'))
|
||||
.and_then(|dt| dt.strip_prefix("datatable://"));
|
||||
.and_then(|dt| dt.strip_prefix("datatable://"))
|
||||
// `?role=` picks the connection, not the data table. Anything else after a `?` may be
|
||||
// part of a name stored before names were restricted, so it stays.
|
||||
.map(|dt| {
|
||||
windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name)
|
||||
});
|
||||
if let Some(datatable) = datatable {
|
||||
let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap();
|
||||
let (schema, content) = if let Some(captures) = re.captures(&preview.content) {
|
||||
|
||||
@@ -352,6 +352,17 @@ async fn update_username_in_workpsace<'c>(
|
||||
new_username: &str,
|
||||
w_id: &str,
|
||||
) -> error::Result<()> {
|
||||
// ---- data table tenants ----
|
||||
// Tenants name the user, so the rename has to follow here too; a list left naming the old
|
||||
// username silently drops the access instead of moving it.
|
||||
windmill_common::workspaces::rename_datatable_tenant_in_workspace(
|
||||
tx,
|
||||
w_id,
|
||||
&format!("u/{old_username}"),
|
||||
&format!("u/{new_username}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- instance and workspace users ----
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET username = $1 WHERE email = $2",
|
||||
|
||||
@@ -1639,7 +1639,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: row.datatable.clone(),
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(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(),
|
||||
@@ -1703,7 +1703,7 @@ pub(crate) async fn tarball_workspace(
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color,
|
||||
operator_settings: row.operator_settings,
|
||||
datatable: row.datatable,
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable),
|
||||
slack_team_id: row.slack_team_id,
|
||||
slack_name: row.slack_name,
|
||||
slack_command_script: row.slack_command_script,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! The instance's data table role catalog.
|
||||
//!
|
||||
//! A data table role is a real Postgres login role on the Windmill cluster, named exactly as the
|
||||
//! user named it, shared by every instance database. Windmill decides who may ask for a role (the
|
||||
//! per-data-table tenant lists in [`crate::workspaces`]); Postgres decides what the role may then
|
||||
//! touch. The catalog here is only the first half's vocabulary plus the cluster provisioning.
|
||||
//!
|
||||
//! Entries are keyed by a generated id so a rename moves nothing else: tenants name the id.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// The connection every data table resolved to before roles existed (`custom_instance_user`). It
|
||||
/// owns every pre-existing object, so it is a reserved name rather than a catalog entry: never
|
||||
/// created, renamed or dropped.
|
||||
pub const ADMIN_DATATABLE_ROLE: &str = "admin";
|
||||
|
||||
/// The login the admin connection uses, and the role every created role is granted to — that
|
||||
/// membership is what later lets it `ALTER ... OWNER TO` a role and drop it.
|
||||
pub const CUSTOM_INSTANCE_USER: &str = "custom_instance_user";
|
||||
|
||||
/// One catalog entry, as stored in `datatable_role`. The password is per role and instance-wide;
|
||||
/// it belongs to the instance, not to any workspace's settings.
|
||||
/// No `Serialize`/`Deserialize`: the catalog is rows now, and a derived `Serialize` would emit
|
||||
/// `pwd` — the same way out for a credential that the hand-written `Debug` below closes on the log
|
||||
/// side.
|
||||
#[derive(Clone)]
|
||||
pub struct InstanceDatatableRole {
|
||||
/// The Postgres role name, verbatim.
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
/// Absent only for a role whose provisioning did not finish; resolving as it then errors
|
||||
/// rather than falling back to admin.
|
||||
///
|
||||
/// A plain string rather than a `StringOrSecretRef` like the instance user's password: that
|
||||
/// one is a secret ref because an operator supplies it and may want it to come from their own
|
||||
/// backend, while this one is minted here and never entered by anyone, so there is nothing for
|
||||
/// a ref to point at. Encrypting generated secrets at rest is a separate change that would
|
||||
/// take the replication password with it.
|
||||
pub pwd: Option<String>,
|
||||
}
|
||||
|
||||
/// Hand-written so `{:?}` on a catalog cannot put a live Postgres password in a log line or an
|
||||
/// audit record. Everything else about the entry is safe to print.
|
||||
impl std::fmt::Debug for InstanceDatatableRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("InstanceDatatableRole")
|
||||
.field("name", &self.name)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("pwd", &self.pwd.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub type DatatableRoleCatalog = BTreeMap<String, InstanceDatatableRole>;
|
||||
|
||||
/// Names Postgres or Windmill already owns. `admin` is excluded because it never reaches the
|
||||
/// cluster as a role name at all — it resolves to `custom_instance_user`.
|
||||
fn is_reserved_role_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower == ADMIN_DATATABLE_ROLE
|
||||
|| lower == "postgres"
|
||||
|| lower == "public"
|
||||
|| lower.starts_with("pg_")
|
||||
|| lower.starts_with("windmill_")
|
||||
|| lower.starts_with("custom_instance_")
|
||||
}
|
||||
|
||||
/// The charset is what makes every downstream interpolation safe: the name reaches Postgres as a
|
||||
/// quoted identifier, a `-- role <name>` annotation, and a `?role=` query parameter.
|
||||
pub fn validate_role_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() || name.len() > 63 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid data table role name '{name}': it must be between 1 and 63 characters"
|
||||
)));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid data table role name '{name}': only letters, digits, '_' and '-' are allowed"
|
||||
)));
|
||||
}
|
||||
if is_reserved_role_name(name) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{name}' is reserved and cannot be used as a data table role name"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this
|
||||
/// quotes any name — schema, table or role. Role names are validated as well
|
||||
/// ([`validate_role_name`]) because they also travel unquoted, in `-- role <name>` and `?role=`.
|
||||
pub fn quote_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// Serialize the mutations that are not already serialized by the row itself.
|
||||
///
|
||||
/// A create is an insert and a delete is a delete, which Postgres orders for us — the unique index
|
||||
/// on `name` is what makes two concurrent creates of the same name one winner and one error. What
|
||||
/// still needs it is the window between the cluster DDL and the row: `CREATE ROLE` is not visible
|
||||
/// to another transaction's `pg_roles` check until commit, so without this two creates of the same
|
||||
/// name both pass their existence check and one fails on the index having already made the login.
|
||||
/// Held for the transaction, so the DDL has to run on that same transaction to be covered.
|
||||
pub async fn lock_role_catalog(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<()> {
|
||||
sqlx::query!("SELECT pg_advisory_xact_lock(hashtext('datatable_role_catalog'))")
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A replication stream reads every row whatever a data table's roles grant. Turning roles on looks
|
||||
/// for streams holding this exclusive; whatever can start a Postgres trigger or capture streaming
|
||||
/// holds it shared on the transaction that commits it. So either the look sees the stream, or the
|
||||
/// stream's listener connects after roles are committed and refuses. Held for the transaction.
|
||||
pub async fn lock_datatable_streams(conn: &mut sqlx::PgConnection, exclusive: bool) -> Result<()> {
|
||||
let lock = if exclusive {
|
||||
"pg_advisory_xact_lock"
|
||||
} else {
|
||||
"pg_advisory_xact_lock_shared"
|
||||
};
|
||||
sqlx::query(&format!("SELECT {lock}(hashtext('datatable_streams'))"))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an instance database is reached only through entries under roles is decided by two
|
||||
/// writes that lock different workspaces' settings rows: turning roles on for one entry, and a
|
||||
/// settings save pointing an entry without roles at the database. Each holds this for every
|
||||
/// database it decides on, so neither reads past the other's uncommitted write. Held for the
|
||||
/// transaction; the names are locked in sorted order so two holders cannot deadlock.
|
||||
pub async fn lock_instance_databases_governance<'a>(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
dbnames: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<()> {
|
||||
let dbnames: std::collections::BTreeSet<&str> = dbnames.into_iter().collect();
|
||||
for dbname in dbnames {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('datatable_instance_database:' || $1))")
|
||||
.bind(dbname)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disclosure: returns every role's stored Postgres password in plaintext. Any server path that
|
||||
/// has to resolve or name a role may call it — including handlers open to a workspace member, who
|
||||
/// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record
|
||||
/// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is
|
||||
/// hand-written to redact it for the same reason.
|
||||
pub async fn read_role_catalog(db: &DB) -> Result<DatatableRoleCatalog> {
|
||||
crate::datatable_roles_oss::read_role_catalog(db).await
|
||||
}
|
||||
|
||||
/// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one
|
||||
/// [`lock_role_catalog`] is protecting. Same disclosure contract.
|
||||
pub async fn read_role_catalog_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<DatatableRoleCatalog> {
|
||||
crate::datatable_roles_oss::read_role_catalog_tx(tx).await
|
||||
}
|
||||
|
||||
/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes.
|
||||
///
|
||||
/// Authorization: writes a generated Postgres credential. Callers MUST restrict this to superadmin
|
||||
/// paths and MUST hold [`lock_role_catalog`] on `tx`.
|
||||
pub async fn insert_role_catalog_entry(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: &str,
|
||||
role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await
|
||||
}
|
||||
|
||||
/// Update a role's recorded name, login flag and password. Same contract as
|
||||
/// [`insert_role_catalog_entry`].
|
||||
pub async fn update_role_catalog_entry(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: &str,
|
||||
role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::update_role_catalog_entry(tx, id, role).await
|
||||
}
|
||||
|
||||
/// Forget a role. Same contract as [`insert_role_catalog_entry`]; run it in the transaction that
|
||||
/// drops the cluster login, so the two cannot disagree.
|
||||
pub async fn delete_role_catalog_entry(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::delete_role_catalog_entry(tx, id).await
|
||||
}
|
||||
|
||||
/// Resolve the role a caller named to its catalog id. A disabled role is an error rather than a
|
||||
/// silent fallback: the caller asked for something the instance deliberately turned off.
|
||||
pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Result<&'a str> {
|
||||
let entry = catalog
|
||||
.iter()
|
||||
.find(|(_, role)| role.name == name)
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"'{name}' is not a data table role of this instance. Defined roles: {}.",
|
||||
catalog
|
||||
.values()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
})?;
|
||||
if !entry.1.enabled {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table role '{name}' is disabled on this instance"
|
||||
)));
|
||||
}
|
||||
Ok(entry.0.as_str())
|
||||
}
|
||||
|
||||
/// Every instance database the registry knows about. Role provisioning has to reach all of them:
|
||||
/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters.
|
||||
///
|
||||
/// Authorization: checks nothing, and names every instance database across all workspaces. Callers
|
||||
/// MUST be superadmin-gated or keep the names server-side; never return them to a workspace caller.
|
||||
pub async fn registered_instance_databases(db: &DB) -> Result<Vec<String>> {
|
||||
crate::datatable_roles_oss::registered_instance_databases(db).await
|
||||
}
|
||||
|
||||
/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at
|
||||
/// database creation, and lazily whenever an instance data table is administered, so a database
|
||||
/// provisioned before a role existed is repaired rather than left silently unreachable.
|
||||
///
|
||||
/// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing.
|
||||
/// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the
|
||||
/// workspace governing a data table on it.
|
||||
pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> {
|
||||
crate::datatable_roles_oss::converge_connect_grants(db, dbname).await
|
||||
}
|
||||
|
||||
/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract.
|
||||
pub async fn converge_connect_grants_with(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
catalog: &DatatableRoleCatalog,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await
|
||||
}
|
||||
|
||||
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user`, and `CONNECT` on
|
||||
/// every registered database. No privileges beyond that — an admin grants them through SQL or the
|
||||
/// ACL editor.
|
||||
///
|
||||
/// Authorization: creates a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
|
||||
pub async fn create_instance_role(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::create_instance_role(tx, name, password).await
|
||||
}
|
||||
|
||||
/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
|
||||
pub async fn set_instance_role_login(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await
|
||||
}
|
||||
|
||||
/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one.
|
||||
///
|
||||
/// Authorization: renames a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
|
||||
pub async fn rename_instance_role(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
from: &str,
|
||||
to: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await
|
||||
}
|
||||
|
||||
/// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the
|
||||
/// privileges granted to it are only visible from inside each database — hence the pass over the
|
||||
/// registry. An unreachable database aborts the whole delete: dropping the role while one database
|
||||
/// still holds objects owned by it leaves those objects owned by a numeric OID nobody can name.
|
||||
///
|
||||
/// Each pass runs as the instance's own Postgres user rather than `custom_instance_user`, which
|
||||
/// owns the databases and can therefore revoke a grant whoever made it. `custom_instance_user`
|
||||
/// could only undo what it granted itself, so a privilege planted by an operator in psql — the
|
||||
/// ordinary way privileges reach a role — would survive and block the drop.
|
||||
///
|
||||
/// Authorization: drops a cluster-wide Postgres login and reassigns everything it owns. Callers
|
||||
/// MUST restrict this to superadmin paths, and MUST hold [`lock_role_catalog`] on `tx`.
|
||||
///
|
||||
/// The per-database passes open their own connections and cannot join `tx`; the lock is what keeps
|
||||
/// a concurrent mutation out while they run. Only the final `DROP ROLE` is on `tx`, so it commits
|
||||
/// or rolls back with the catalog write that forgets the role. Those passes commit as they go, so
|
||||
/// callers MUST have disabled the role in an earlier committed transaction: a failure part-way
|
||||
/// then leaves a disabled role to retry, not an enabled one already stripped in some databases.
|
||||
pub async fn drop_instance_role(
|
||||
db: &DB,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::drop_instance_role(db, tx, name).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn role_names_are_validated() {
|
||||
assert!(validate_role_name("analytics").is_ok());
|
||||
assert!(validate_role_name("read-only_2").is_ok());
|
||||
assert!(validate_role_name("").is_err());
|
||||
assert!(validate_role_name(&"a".repeat(64)).is_err());
|
||||
assert!(validate_role_name("has space").is_err());
|
||||
assert!(validate_role_name("quote\"injection").is_err());
|
||||
// Reserved, case-insensitively.
|
||||
assert!(validate_role_name("admin").is_err());
|
||||
assert!(validate_role_name("Postgres").is_err());
|
||||
assert!(validate_role_name("pg_read_all_data").is_err());
|
||||
assert!(validate_role_name("windmill_user").is_err());
|
||||
assert!(validate_role_name("custom_instance_user").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_role_is_an_error_not_a_fallback() {
|
||||
let mut catalog = DatatableRoleCatalog::new();
|
||||
catalog.insert(
|
||||
"id1".to_string(),
|
||||
InstanceDatatableRole {
|
||||
name: "analytics".to_string(),
|
||||
enabled: false,
|
||||
pwd: Some("x".to_string()),
|
||||
},
|
||||
);
|
||||
assert!(role_id_by_name(&catalog, "analytics").is_err());
|
||||
assert!(role_id_by_name(&catalog, "nope").is_err());
|
||||
catalog.get_mut("id1").unwrap().enabled = true;
|
||||
assert_eq!(role_id_by_name(&catalog, "analytics").unwrap(), "id1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where data table roles come from: the enterprise implementation, or a refusal.
|
||||
//!
|
||||
//! Roles are an Enterprise Edition feature. An edition without them creates, grants and connects
|
||||
//! as none, and a data table saved under roles — by an enterprise build, before a downgrade — is
|
||||
//! refused rather than resolved as `admin`. A data table not under roles, asked for no role,
|
||||
//! resolves as it always has. `private` alone is not that edition: community builds carry it.
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// What every roles path answers without the Enterprise Edition. The frontend matches this exact
|
||||
/// sentence (`datatableUsableRoles.ts`) to read the refusal as "not under roles": reword both.
|
||||
pub fn datatable_roles_unavailable() -> Error {
|
||||
Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_roles_ee::{
|
||||
can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants,
|
||||
converge_connect_grants_with, create_instance_role, delete_role_catalog_entry,
|
||||
drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access,
|
||||
ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere,
|
||||
insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx,
|
||||
registered_instance_databases, rename_instance_role, resolve_datatable_role_connection,
|
||||
set_instance_role_login, update_role_catalog_entry,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use super::datatable_roles_unavailable as unavailable;
|
||||
use crate::{
|
||||
datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole},
|
||||
db::AuthedRef,
|
||||
error::Result,
|
||||
workspaces::{
|
||||
resolve_governing_datatable, DataTableRoleTenants, DatatableAccess, GoverningDatatable,
|
||||
},
|
||||
DB,
|
||||
};
|
||||
|
||||
type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
|
||||
|
||||
pub(crate) async fn read_role_catalog(_db: &DB) -> Result<DatatableRoleCatalog> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result<DatatableRoleCatalog> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_role_catalog_entry(
|
||||
_tx: &mut Tx<'_>,
|
||||
_id: &str,
|
||||
_role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_role_catalog_entry(
|
||||
_tx: &mut Tx<'_>,
|
||||
_id: &str,
|
||||
_role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_role_catalog_entry(_tx: &mut Tx<'_>, _id: &str) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn registered_instance_databases(_db: &DB) -> Result<Vec<String>> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT`
|
||||
/// grants it was created with, `PUBLIC`'s included, as it did before roles existed.
|
||||
pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// As [`converge_connect_grants`].
|
||||
pub(crate) async fn converge_connect_grants_with(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
_catalog: &DatatableRoleCatalog,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn create_instance_role(
|
||||
_tx: &mut Tx<'_>,
|
||||
_name: &str,
|
||||
_password: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_role_login(
|
||||
_tx: &mut Tx<'_>,
|
||||
_name: &str,
|
||||
_enabled: bool,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_instance_role(
|
||||
_tx: &mut Tx<'_>,
|
||||
_from: &str,
|
||||
_to: &str,
|
||||
_password: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_instance_db_grant_options_unchecked(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// No tenant list covers anyone: there is no role to connect as.
|
||||
pub(crate) fn can_use_datatable_role(
|
||||
_tenants: &DataTableRoleTenants,
|
||||
_authed: &AuthedRef<'_>,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) async fn can_use_datatable_role_in_governing_workspace(
|
||||
_db: &DB,
|
||||
_governing_w_id: &str,
|
||||
_w_id: &str,
|
||||
_tenants: &DataTableRoleTenants,
|
||||
_access: &DatatableAccess<'_>,
|
||||
) -> Result<bool> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// Reached only for a data table under roles or a caller naming a role: both are refused.
|
||||
pub(crate) async fn resolve_datatable_role_connection(
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_name: &str,
|
||||
_governing: &GoverningDatatable,
|
||||
_db_resource: serde_json::Value,
|
||||
_role: Option<&str>,
|
||||
_access: DatatableAccess<'_>,
|
||||
) -> Result<serde_json::Value> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// A data table not under roles, asked for no role or for `admin`, is not a role decision and
|
||||
/// passes, as it did before roles existed. Anything else is refused.
|
||||
pub(crate) async fn ensure_can_use_datatable_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
_access: &DatatableAccess<'_>,
|
||||
_context: &str,
|
||||
) -> Result<()> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
if governing.datatable.permissions.is_none()
|
||||
&& role.is_none_or(|r| r == crate::datatable_roles::ADMIN_DATATABLE_ROLE)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
/// A data table not under roles is the `admin` connection for anyone who reaches it, as before
|
||||
/// roles existed. One under roles is refused.
|
||||
pub(crate) async fn ensure_datatable_admin_access(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
_access: &DatatableAccess<'_>,
|
||||
) -> Result<()> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
if governing.datatable.permissions.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn forget_datatable_role_everywhere(
|
||||
_tx: &mut Tx<'_>,
|
||||
_role_id: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
@@ -809,6 +809,9 @@ pub struct CustomInstanceDb {
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
/// The workspace a member created this fork copy for. Absent when a superadmin created it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Setup log entries for a custom instance database.
|
||||
|
||||
@@ -37,6 +37,10 @@ pub mod bench;
|
||||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod data_metrics;
|
||||
pub mod datatable_roles;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod datatable_roles_ee;
|
||||
pub mod datatable_roles_oss;
|
||||
pub mod db;
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
mod db_entra_ee;
|
||||
@@ -1020,6 +1024,20 @@ impl Future for TokioPgConnection {
|
||||
}
|
||||
}
|
||||
|
||||
impl TokioPgConnection {
|
||||
/// Drive the connection and hand back what the server sends outside of a query's response —
|
||||
/// notices above all, which driving it as a future silently discards.
|
||||
pub fn poll_message(
|
||||
&mut self,
|
||||
cx: &mut core::task::Context<'_>,
|
||||
) -> core::task::Poll<Option<Result<tokio_postgres::AsyncMessage, tokio_postgres::Error>>> {
|
||||
match self {
|
||||
TokioPgConnection::Tls(conn) => conn.poll_message(cx),
|
||||
TokioPgConnection::NoTls(conn) => conn.poll_message(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PgDatabase {
|
||||
/// The role the connection logs in as, whichever way it authenticates.
|
||||
pub fn login_name(&self) -> &str {
|
||||
@@ -1514,12 +1532,49 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `custom_instance_user` holds on an instance database.
|
||||
///
|
||||
/// `WITH GRANT OPTION` throughout: this is the connection every data table resolves to as `admin`,
|
||||
/// and it is the one that hands privileges to data table roles. Postgres refuses to let a role pass
|
||||
/// on a privilege it does not itself hold with grant option, so without these an admin could own
|
||||
/// the database and still be unable to grant `SELECT` on it to `analytics`.
|
||||
pub(crate) fn instance_db_grants(dbname: &str) -> String {
|
||||
format!(
|
||||
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
|
||||
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') THEN
|
||||
GRANT USAGE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION;
|
||||
GRANT CREATE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;
|
||||
END IF;
|
||||
END $$;"
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-apply [`instance_db_grants`] to an instance database provisioned before data table roles
|
||||
/// existed, whose grants carry no grant option. Connects as the instance's own Postgres user —
|
||||
/// the database and `public` schema owner — since only it can hand out an option it holds.
|
||||
///
|
||||
/// Authorization: reaches an instance database with the server's own credentials and checks
|
||||
/// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of
|
||||
/// the workspace governing a data table on it.
|
||||
pub async fn ensure_instance_db_grant_options_unchecked(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
) -> error::Result<()> {
|
||||
crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await
|
||||
}
|
||||
|
||||
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
|
||||
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake").
|
||||
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake"). `for_workspace`
|
||||
/// is the workspace a member creates a fork copy for; see [`ensure_fork_database_available_to`].
|
||||
pub async fn create_custom_instance_database(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
tag: &str,
|
||||
for_workspace: Option<&str>,
|
||||
) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
validate_dbname(dbname)?;
|
||||
@@ -1553,17 +1608,7 @@ pub async fn create_custom_instance_database(
|
||||
let (client, connection) = new_pg_creds.connect(Some(db)).await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
if let Err(e) = client
|
||||
.batch_execute(&format!(
|
||||
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user;
|
||||
GRANT USAGE ON SCHEMA public TO custom_instance_user;
|
||||
GRANT CREATE ON SCHEMA public TO custom_instance_user;
|
||||
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;"
|
||||
))
|
||||
.await
|
||||
{
|
||||
if let Err(e) = client.batch_execute(&instance_db_grants(dbname)).await {
|
||||
tracing::warn!(
|
||||
"Failed to grant permissions on '{}': {}. Continuing.",
|
||||
dbname,
|
||||
@@ -1583,7 +1628,8 @@ pub async fn create_custom_instance_database(
|
||||
},
|
||||
"success": true,
|
||||
"error": null,
|
||||
"tag": tag
|
||||
"tag": tag,
|
||||
"workspace_id": for_workspace,
|
||||
});
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#,
|
||||
@@ -1592,10 +1638,59 @@ pub async fn create_custom_instance_database(
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT
|
||||
// would otherwise let every role in regardless of what this instance defines. Best-effort: a
|
||||
// failure here leaves the database usable as `admin`, and the next role change repairs it.
|
||||
if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await {
|
||||
tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}");
|
||||
}
|
||||
|
||||
tracing::info!("Created custom instance database '{}'", dbname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse a workspace member writing a fork copy into, or pointing a fork at, the instance database
|
||||
/// `dbname`, unless `w_id` created it for that ([`create_custom_instance_database`]) and nothing uses
|
||||
/// it yet. The `wm_fork_` prefix is no authorization: every instance database answers to the same
|
||||
/// `custom_instance_user`, so a name is all it takes to reach another workspace's copy.
|
||||
///
|
||||
/// Authorization: reads the global registry and every workspace's settings, and names other
|
||||
/// workspaces in its refusal. Callers MUST have authorized `w_id` for the caller first — a member
|
||||
/// of it forking or importing there — and MUST NOT call it on a workspace the caller is not in.
|
||||
pub async fn ensure_fork_database_available_to(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
w_id: &str,
|
||||
) -> error::Result<()> {
|
||||
let created_for = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT value->'databases'->$1->>'workspace_id' FROM global_settings
|
||||
WHERE name = 'custom_instance_pg_databases'",
|
||||
)
|
||||
.bind(dbname)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
if created_for.as_deref() != Some(w_id) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{dbname}' was not created for a fork of workspace '{w_id}'"
|
||||
)));
|
||||
}
|
||||
let uses = workspaces::managed_database_uses(
|
||||
&mut *db.acquire().await?,
|
||||
workspaces::DataTableCatalogResourceType::Instance,
|
||||
dbname,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !uses.is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{dbname}' is already in use: {}",
|
||||
uses.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connection options parsed from a database URL.
|
||||
///
|
||||
/// The only place a database URL becomes `PgConnectOptions`. Providers that mint the password
|
||||
|
||||
@@ -329,6 +329,7 @@ pub fn try_expand_internal_db_query(
|
||||
"ALTER_TABLE" => expand_alter_table(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"CREATE_SCHEMA" => expand_create_schema(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"DROP_SCHEMA" => expand_drop_schema(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"RENAME_SCHEMA" => expand_rename_schema(json_str, db_type).map(ExpandedQuery::sql),
|
||||
// Metadata queries
|
||||
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
|
||||
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
|
||||
@@ -1716,6 +1717,13 @@ struct DropSchemaPayload {
|
||||
ducklake: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RenameSchemaPayload {
|
||||
schema: String,
|
||||
new_schema: String,
|
||||
ducklake: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct TableEditorColumn {
|
||||
name: String,
|
||||
@@ -2004,6 +2012,23 @@ fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result<String, String>
|
||||
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
||||
}
|
||||
|
||||
fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: RenameSchemaPayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?;
|
||||
if !matches!(db_type, DbType::Postgresql | DbType::Snowflake) || p.ducklake.is_some() {
|
||||
return Err(format!(
|
||||
"Renaming a schema is not supported on {:?}",
|
||||
db_type
|
||||
));
|
||||
}
|
||||
let query = format!(
|
||||
"ALTER SCHEMA {} RENAME TO {};",
|
||||
qi(&p.schema, db_type),
|
||||
qi(&p.new_schema, db_type)
|
||||
);
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
fn expand_create_table(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: CreateTablePayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?;
|
||||
@@ -2598,7 +2623,9 @@ WHERE table_catalog = current_database()",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'".to_string(),
|
||||
// pg_catalog is readable by everyone: without the privilege check this lists
|
||||
// tables of schemas the connection's role cannot even enter.
|
||||
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE')".to_string(),
|
||||
",\n ns.nspname AS schema_name,\n c.relname AS table_name".to_string(),
|
||||
"\nJOIN pg_catalog.pg_class c ON a.attrelid = c.oid\nJOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid".to_string(),
|
||||
"ns.nspname, c.relname, a.attnum".to_string(),
|
||||
@@ -4101,6 +4128,13 @@ mod tests {
|
||||
assert_eq!(sql, "DROP SCHEMA \"old_schema\" CASCADE;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_rename_schema() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
||||
assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_create_schema_with_ducklake() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"s","ducklake":"lake"}"#;
|
||||
@@ -4468,6 +4502,7 @@ mod tests {
|
||||
assert!(sql.contains("schema_name"));
|
||||
assert!(sql.contains("table_name"));
|
||||
assert!(sql.contains("c.relkind = 'r'"));
|
||||
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1082,6 +1082,83 @@ pub struct SqlAnnotations {
|
||||
pub raw_output: bool,
|
||||
}
|
||||
|
||||
impl SqlAnnotations {
|
||||
/// The data table role a query declares as `-- role <name>`, if any. Only meaningful against a
|
||||
/// `datatable://` database that is under roles; absent means the data table's default role.
|
||||
///
|
||||
/// Hand-written rather than derived because the value matters, not just the presence, and
|
||||
/// because the executor needs it before it knows the connection is a data table at all. Like
|
||||
/// every annotation it lives in the leading comment block.
|
||||
///
|
||||
/// A leading comment whose first word is `role` is an annotation *attempt*, and a malformed
|
||||
/// one is an error. The alternative — ignoring what does not parse — resolves the query to the
|
||||
/// data table's default role instead, so a typo silently runs it under a login the author did
|
||||
/// not choose, which is the opposite of what naming a role is for. Only callers that already
|
||||
/// know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its
|
||||
/// comments.
|
||||
pub fn datatable_role(code: &str) -> error::Result<Option<String>> {
|
||||
for line in code.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !line.starts_with("--") {
|
||||
break;
|
||||
}
|
||||
// The keyword may be followed by whitespace, `:` or `=` — `role x`, `role: x`,
|
||||
// `role=x`, `Role = x` all open an attempt, while `rolexyz` does not. Each accepted
|
||||
// separator is one spelling that would otherwise take the `continue` below and run the
|
||||
// query as the data table's default role, which is the silence this exists to remove.
|
||||
let body = line[2..].trim_start();
|
||||
let Some(after) = body
|
||||
.get(..4)
|
||||
.filter(|kw| kw.eq_ignore_ascii_case("role"))
|
||||
.map(|_| &body[4..])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !after.is_empty()
|
||||
&& !after.starts_with(char::is_whitespace)
|
||||
&& !after.starts_with([':', '='])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Past this point the line is an attempt to name a role, so a malformed one is an
|
||||
// error rather than a miss. Falling through would run the query as the data table's
|
||||
// default role — quietly, and under a login the author did not choose.
|
||||
let after = after.trim_start();
|
||||
let after = after.strip_prefix([':', '=']).unwrap_or(after);
|
||||
let mut tokens = after.split_whitespace();
|
||||
let role = tokens
|
||||
.next()
|
||||
.map(|role| role.strip_suffix(';').unwrap_or(role));
|
||||
let rest = tokens.next();
|
||||
match (role, rest) {
|
||||
(Some(role), None)
|
||||
if !role.is_empty()
|
||||
&& role.len() <= 63
|
||||
&& role
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
|
||||
{
|
||||
return Ok(Some(role.to_string()));
|
||||
}
|
||||
_ => {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Malformed data table role annotation: `{line}`. Write it as \
|
||||
`-- role <name>` on a line of its own, where <name> is letters, digits, \
|
||||
'_' or '-'. A comment in the leading block that starts with the word \
|
||||
'role' is read as this annotation; move it below the first statement if \
|
||||
it is prose."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[annotations("#")]
|
||||
pub struct BashAnnotations {
|
||||
pub docker: bool,
|
||||
@@ -2653,6 +2730,56 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn datatable_role_is_read_from_the_leading_comment_block() {
|
||||
let role = |code| SqlAnnotations::datatable_role(code);
|
||||
assert_eq!(
|
||||
role("-- role analytics\nSELECT 1").unwrap(),
|
||||
Some("analytics".to_string())
|
||||
);
|
||||
// Blank lines and other annotations before it are fine.
|
||||
assert_eq!(
|
||||
role("\n-- prepare\n-- role read_only\nSELECT 1").unwrap(),
|
||||
Some("read_only".to_string())
|
||||
);
|
||||
// Past the first statement it is an ordinary comment, not an annotation.
|
||||
assert_eq!(role("SELECT 1;\n-- role analytics").unwrap(), None);
|
||||
assert_eq!(role("SELECT 1").unwrap(), None);
|
||||
|
||||
// Unambiguous intent is honoured: the keyword matches case-insensitively, a trailing
|
||||
// semicolon is a habit carried over from SQL rather than a different role, and the colon
|
||||
// spelling is the one most likely to be typed.
|
||||
for accepted in [
|
||||
"-- Role operator\nSELECT 1",
|
||||
"-- role operator;\nSELECT 1",
|
||||
"-- role: operator\nSELECT 1",
|
||||
"-- role:operator\nSELECT 1",
|
||||
"-- role=operator\nSELECT 1",
|
||||
"-- Role = operator\nSELECT 1",
|
||||
] {
|
||||
assert_eq!(
|
||||
role(accepted).unwrap(),
|
||||
Some("operator".to_string()),
|
||||
"not honoured: {accepted}"
|
||||
);
|
||||
}
|
||||
|
||||
// Anything else opening with the word is refused rather than resolved to the default role:
|
||||
// the whole point of naming one is to not run as something else.
|
||||
for near_miss in [
|
||||
"-- role operator -- why\nSELECT 1",
|
||||
"-- role an;alytics\nSELECT 1",
|
||||
"-- role\nSELECT 1",
|
||||
"-- role:\nSELECT 1",
|
||||
"-- role based access is handled below\nSELECT 1",
|
||||
] {
|
||||
assert!(role(near_miss).is_err(), "silently ignored: {near_miss}");
|
||||
}
|
||||
|
||||
// A word that merely starts with the keyword is not an attempt.
|
||||
assert_eq!(role("-- rolebased notes\nSELECT 1").unwrap(), None);
|
||||
}
|
||||
|
||||
fn matcher(id: &str) -> WorkspaceMatcher {
|
||||
WorkspaceMatcher { id: id.to_string(), include_forks: false }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use strum::AsRefStr;
|
||||
|
||||
use crate::{
|
||||
datatable_roles::{ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER},
|
||||
error::{self, to_anyhow, Error, Result},
|
||||
get_database_url,
|
||||
secret_backend::{get_secret_value, is_external_stored_value},
|
||||
@@ -1292,9 +1293,17 @@ impl Default for DataTableForkBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DataTable {
|
||||
pub database: DataTableDatabase,
|
||||
/// Set on a *terminal* entry — one that owns its database. Mutually exclusive with
|
||||
/// [`DataTable::reference`]; [`validate_datatable_shape`] is the one place that enforces it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database: Option<DataTableDatabase>,
|
||||
/// Set on a *pointer* entry — one that names another workspace's entry and owns nothing.
|
||||
/// A keep-original fork gets one of these instead of a copy of the parent's entry, so there is
|
||||
/// nothing local for a fork admin to widen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<DataTableReference>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from: Option<DataTableForkedFrom>,
|
||||
/// Whether the SQL-migrations feature is opted in for this data table.
|
||||
@@ -1302,22 +1311,85 @@ pub struct DataTable {
|
||||
/// when migrations already exist (see `datatable_migrations_enabled`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub migrations_enabled: Option<bool>,
|
||||
/// Who may connect as which role. Absent = unpermissioned: every caller connects as `admin`,
|
||||
/// which is how data tables behaved before roles existed. Only meaningful on a terminal entry;
|
||||
/// a pointer is governed by what it points at.
|
||||
///
|
||||
/// Never leaves the instance: stripped from the workspace export and ignored on import, since
|
||||
/// tenants are workspace-scoped names and syncing them would make repo write access a second
|
||||
/// door onto the access decision.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<DataTablePermissions>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
/// A pointer at another workspace's data table entry.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct DataTableReference {
|
||||
pub workspace_id: String,
|
||||
pub datatable: String,
|
||||
}
|
||||
|
||||
/// The access decision for one data table: which role a caller gets, and who may ask for each.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct DataTablePermissions {
|
||||
/// A role id from the instance catalog, or `admin`. Absent = `admin`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_role: Option<String>,
|
||||
/// Keyed by instance role id, plus the reserved `admin` key. A role absent from this map
|
||||
/// cannot be used on this data table at all, whatever the instance catalog says.
|
||||
#[serde(default)]
|
||||
pub roles: std::collections::BTreeMap<String, DataTableRoleTenants>,
|
||||
}
|
||||
|
||||
impl DataTablePermissions {
|
||||
pub fn default_role(&self) -> &str {
|
||||
self.default_role.as_deref().unwrap_or(ADMIN_DATATABLE_ROLE)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct DataTableRoleTenants {
|
||||
/// `u/<user>`, `g/<group>`, `f/<folder>`, or `*` for every member.
|
||||
#[serde(default)]
|
||||
pub tenants: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every member of the governing workspace.
|
||||
pub const DATATABLE_TENANT_WILDCARD: &str = "*";
|
||||
|
||||
/// How deep a chain of pointer entries may go before it is called a loop. Data tables are not
|
||||
/// expected to chain at all — a fork points at its parent — so this only has to be generous
|
||||
/// enough to survive a fork of a fork.
|
||||
const DATATABLE_REFERENCE_MAX_DEPTH: usize = 20;
|
||||
|
||||
/// Exactly one of `database` and `reference` must be set. Called wherever an entry is persisted,
|
||||
/// so nothing downstream has to handle an entry that is both or neither.
|
||||
pub fn validate_datatable_shape(name: &str, dt: &DataTable) -> Result<()> {
|
||||
match (&dt.database, &dt.reference) {
|
||||
(Some(_), None) | (None, Some(_)) => Ok(()),
|
||||
(Some(_), Some(_)) => Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' both owns a database and points at another one"
|
||||
))),
|
||||
(None, None) => Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' names neither a database nor another data table"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DataTableForkedFrom {
|
||||
/// Schema snapshot at fork time
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DataTableDatabase {
|
||||
pub resource_type: DataTableCatalogResourceType,
|
||||
pub resource_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq)]
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(AsRefStr)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
@@ -1353,37 +1425,13 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_inner(db, w_id, name, false).await
|
||||
}
|
||||
|
||||
/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger
|
||||
/// connections: custom-instance datatables resolve to
|
||||
/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres
|
||||
/// datatables resolve to the user's own resource unchanged; configuring it for
|
||||
/// replication there is the user's responsibility.
|
||||
/// Read one workspace's data table entry, without following a pointer.
|
||||
///
|
||||
/// Authorization: like its `_unchecked` sibling, returns resolved connection
|
||||
/// credentials and performs no authorization — callers MUST have already authorized
|
||||
/// access to the datatable (e.g. the trigger's own create-time check).
|
||||
pub async fn get_datatable_replication_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_inner(db, w_id, name, true).await
|
||||
}
|
||||
|
||||
async fn get_datatable_resource_inner(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
replication: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
/// Disclosure: this is the primitive [`resolve_governing_datatable`] calls on every path, so it is
|
||||
/// deliberately open to anything that has to resolve a data table, including for a workspace the
|
||||
/// caller does not belong to. What it returns is not: callers MUST NOT put `permissions` into a
|
||||
/// response, an export or a log — it names the governing workspace's users, groups and folders.
|
||||
pub async fn read_datatable_entry(db: &DB, w_id: &str, name: &str) -> Result<DataTable> {
|
||||
let datatables = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT ws.datatable->'datatables' AS datatables
|
||||
@@ -1401,37 +1449,669 @@ async fn get_datatable_resource_inner(
|
||||
.and_then(|d| d.get(name))
|
||||
.filter(|v| !v.is_null())
|
||||
.ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?;
|
||||
let datatable = serde_json::from_value::<DataTable>(datatable.clone())?;
|
||||
Ok(serde_json::from_value::<DataTable>(datatable.clone())?)
|
||||
}
|
||||
|
||||
let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance
|
||||
{
|
||||
/// The terminal entry a reference chain lands on: the workspace that governs the data table, the
|
||||
/// entry name there, and the entry itself. A terminal entry resolves to itself.
|
||||
///
|
||||
/// Every decision downstream — which database to connect to, whose `permissions` apply, whose
|
||||
/// members tenants are evaluated against, who may administer it — is taken on this, never on the
|
||||
/// entry the caller named.
|
||||
///
|
||||
/// Authorization: resolving deliberately crosses into the governing workspace, so it answers for a
|
||||
/// workspace the caller may not belong to and checks nothing itself. It is the input to the
|
||||
/// checks, not one of them: callers MUST pass what it returns to
|
||||
/// [`can_use_datatable_role_in_governing_workspace`] or [`ensure_datatable_admin_access`] before
|
||||
/// acting on it, and MUST NOT return its `permissions` or `workspace_id` to a caller from
|
||||
/// elsewhere without gating on the answer.
|
||||
pub struct GoverningDatatable {
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub datatable: DataTable,
|
||||
}
|
||||
|
||||
/// Everything still using the Windmill-managed database `dbname`, one description per use: data
|
||||
/// table entries naming it, fork entries pointing at those, Ducklake catalogs on it, and fork
|
||||
/// Ducklake metadata schemas there that cleanup has not dropped yet. `exempt` is the one data table
|
||||
/// entry, `(workspace_id, name)`, the caller is about to stop using it through; pointers at that
|
||||
/// entry still count, since dropping the database would leave them resolving to nothing.
|
||||
///
|
||||
/// Authorization: reads every workspace's settings and checks nothing. Callers MUST only turn the
|
||||
/// answer into a refusal for someone allowed to administer `dbname`.
|
||||
pub async fn managed_database_uses(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
kind: DataTableCatalogResourceType,
|
||||
dbname: &str,
|
||||
exempt: Option<(&str, &str)>,
|
||||
) -> Result<Vec<String>> {
|
||||
let (exempt_workspace, exempt_name) = exempt.unzip();
|
||||
Ok(sqlx::query_scalar::<_, String>(
|
||||
"WITH entries AS (
|
||||
SELECT ws.workspace_id::text AS workspace_id, dt.key AS name, dt.value
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt
|
||||
), naming AS (
|
||||
SELECT workspace_id, name FROM entries
|
||||
WHERE value->'database'->>'resource_type' = $1
|
||||
AND value->'database'->>'resource_path' = $2
|
||||
)
|
||||
SELECT format('data table ''%s'' in workspace ''%s''', name, workspace_id) FROM naming
|
||||
WHERE $3::text IS NULL OR NOT (workspace_id = $3 AND name = $4)
|
||||
UNION ALL
|
||||
SELECT format('data table ''%s'' in workspace ''%s'', which points at the one in ''%s''',
|
||||
e.name, e.workspace_id, n.workspace_id)
|
||||
FROM entries e JOIN naming n
|
||||
ON e.value->'reference'->>'workspace_id' = n.workspace_id
|
||||
AND e.value->'reference'->>'datatable' = n.name
|
||||
UNION ALL
|
||||
SELECT format('Ducklake ''%s'' in workspace ''%s''', dl.key, ws.workspace_id)
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'
|
||||
THEN ws.ducklake->'ducklakes' ELSE '{}'::jsonb END) dl
|
||||
WHERE dl.value->'catalog'->>'resource_type' = $1
|
||||
AND dl.value->'catalog'->>'resource_path' = $2
|
||||
UNION ALL
|
||||
SELECT format('the Ducklake namespace of fork ''%s'', not cleaned up yet', workspace_id)
|
||||
FROM fork_ducklake_namespace
|
||||
WHERE catalog = $1 || ':' || $2 AND NOT schema_dropped
|
||||
ORDER BY 1",
|
||||
)
|
||||
.bind(kind.as_ref())
|
||||
.bind(dbname)
|
||||
.bind(exempt_workspace)
|
||||
.bind(exempt_name)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Held by fork cleanup of `w_id`'s data tables and by forking `w_id`, which can hand the new fork
|
||||
/// pointers at them, so a pointer cannot appear between cleanup's check and its drop.
|
||||
pub async fn lock_fork_datatables(conn: &mut sqlx::PgConnection, w_id: &str) -> Result<()> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork_datatables:' || $1))")
|
||||
.bind(w_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl GoverningDatatable {
|
||||
/// Backed by the Windmill instance's own Postgres, which is the only substrate data table
|
||||
/// roles apply to.
|
||||
pub fn is_instance(&self) -> bool {
|
||||
self.datatable
|
||||
.database
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_governing_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<GoverningDatatable> {
|
||||
let mut workspace_id = w_id.to_string();
|
||||
let mut name = name.to_string();
|
||||
let mut hops = 0;
|
||||
for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH {
|
||||
let datatable = read_datatable_entry(db, &workspace_id, &name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if hops == 0 {
|
||||
e
|
||||
} else {
|
||||
// A pointer outlives the workspace it names: deleting one only nulls the fork
|
||||
// lineage, it does not sweep the entries that pointed at it. Say which one is
|
||||
// gone rather than reporting a data table this workspace never had.
|
||||
Error::NotFound(format!(
|
||||
"Data table '{name}' of workspace '{workspace_id}' governs this one and no \
|
||||
longer exists. A superadmin can point this data table somewhere else."
|
||||
))
|
||||
}
|
||||
})?;
|
||||
hops += 1;
|
||||
validate_datatable_shape(&name, &datatable)?;
|
||||
match &datatable.reference {
|
||||
None => return Ok(GoverningDatatable { workspace_id, name, datatable }),
|
||||
Some(reference) => {
|
||||
workspace_id = reference.workspace_id.clone();
|
||||
name = reference.datatable.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' points at another data table through more than \
|
||||
{DATATABLE_REFERENCE_MAX_DEPTH} hops; the chain is likely a loop"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Every entry of a workspace resolved as [`resolve_governing_datatable`] resolves one, in stored
|
||||
/// order, reading the settings rows one pointer hop at a time rather than once per entry. An entry
|
||||
/// that does not resolve — malformed, a dangling pointer, a loop — is left out. Same authorization
|
||||
/// contract as the single resolution: it checks nothing.
|
||||
pub async fn resolve_workspace_governing_datatables(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
) -> Result<Vec<(String, GoverningDatatable)>> {
|
||||
type Entries =
|
||||
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>;
|
||||
async fn load(db: &DB, workspaces: &[String], entries: &mut Entries) -> Result<Vec<String>> {
|
||||
let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as(
|
||||
"SELECT ws.workspace_id, dt.key, dt.value FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||||
WHERE ws.workspace_id = ANY($1)",
|
||||
)
|
||||
.bind(workspaces)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for ws in workspaces {
|
||||
entries.entry(ws.clone()).or_default();
|
||||
}
|
||||
let mut keys = Vec::with_capacity(rows.len());
|
||||
for (ws, key, value) in rows {
|
||||
keys.push(key.clone());
|
||||
entries.entry(ws).or_default().insert(key, value);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
let mut entries = Entries::new();
|
||||
let listed = load(db, &[w_id.to_string()], &mut entries).await?;
|
||||
// (index into `listed`, workspace, entry name) still to be followed.
|
||||
let mut cursors: Vec<(usize, String, String)> = listed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| (i, w_id.to_string(), name.clone()))
|
||||
.collect();
|
||||
let mut resolved: Vec<(usize, GoverningDatatable)> = vec![];
|
||||
|
||||
for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH {
|
||||
let mut next = vec![];
|
||||
for (i, ws, name) in cursors.drain(..) {
|
||||
let Some(value) = entries
|
||||
.get(&ws)
|
||||
.and_then(|m| m.get(&name))
|
||||
.filter(|v| !v.is_null())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(datatable) = serde_json::from_value::<DataTable>(value.clone()) else {
|
||||
continue;
|
||||
};
|
||||
if validate_datatable_shape(&name, &datatable).is_err() {
|
||||
continue;
|
||||
}
|
||||
match &datatable.reference {
|
||||
None => {
|
||||
resolved.push((i, GoverningDatatable { workspace_id: ws, name, datatable }))
|
||||
}
|
||||
Some(reference) => next.push((
|
||||
i,
|
||||
reference.workspace_id.clone(),
|
||||
reference.datatable.clone(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
if next.is_empty() {
|
||||
break;
|
||||
}
|
||||
let to_load: Vec<String> = next
|
||||
.iter()
|
||||
.map(|(_, ws, _)| ws.clone())
|
||||
.filter(|ws| !entries.contains_key(ws))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if !to_load.is_empty() {
|
||||
load(db, &to_load, &mut entries).await?;
|
||||
}
|
||||
cursors = next;
|
||||
}
|
||||
|
||||
resolved.sort_by_key(|(i, _)| *i);
|
||||
Ok(resolved
|
||||
.into_iter()
|
||||
.map(|(i, governing)| (listed[i].clone(), governing))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build the `admin` connection for a governing entry: `custom_instance_user` for an instance
|
||||
/// database, the user's own resource for a BYO-postgres one.
|
||||
async fn resolve_datatable_connection_unchecked(
|
||||
db: &DB,
|
||||
governing: &GoverningDatatable,
|
||||
replication: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
let database = governing
|
||||
.datatable
|
||||
.database
|
||||
.as_ref()
|
||||
.expect("a governing entry owns a database");
|
||||
if database.resource_type == DataTableCatalogResourceType::Instance {
|
||||
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
pg_creds.dbname = datatable.database.resource_path.clone();
|
||||
pg_creds.dbname = database.resource_path.clone();
|
||||
if replication {
|
||||
pg_creds.user = Some("custom_instance_replication_user".to_string());
|
||||
pg_creds.password = Some(get_custom_pg_instance_replication_password(&db).await?);
|
||||
} else {
|
||||
pg_creds.user = Some("custom_instance_user".to_string());
|
||||
pg_creds.user = Some(CUSTOM_INSTANCE_USER.to_string());
|
||||
pg_creds.password = Some(get_custom_pg_instance_password(&db).await?);
|
||||
}
|
||||
serde_json::to_value(&pg_creds)
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))?
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))
|
||||
} else {
|
||||
// Name the data table too: the caller asked for one by name, and a bare
|
||||
// "resource f/x/y does not exist" leaves them to work out which one points at it.
|
||||
transform_json_unchecked(
|
||||
&serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)),
|
||||
w_id,
|
||||
&serde_json::Value::String(format!("$res:{}", database.resource_path)),
|
||||
&governing.workspace_id,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::NotFound(m) => Error::NotFound(format!("data table {name}: {m}")),
|
||||
Error::NotFound(m) => Error::NotFound(format!("data table {}: {m}", governing.name)),
|
||||
e => e,
|
||||
})?
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a data table to connection credentials **without authorizing anything**: always the
|
||||
/// `admin` connection.
|
||||
///
|
||||
/// Authorization: callers MUST have authorized access already. Anything that acts for a user or a
|
||||
/// job wants [`get_datatable_resource_from_db`] instead.
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
resolve_datatable_connection_unchecked(db, &governing, false).await
|
||||
}
|
||||
|
||||
/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger
|
||||
/// connections: custom-instance datatables resolve to
|
||||
/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres
|
||||
/// datatables resolve to the user's own resource unchanged; configuring it for
|
||||
/// replication there is the user's responsibility.
|
||||
///
|
||||
/// Authorization: a replication connection reads every row whatever the roles grant, so no role or
|
||||
/// admin check makes it safe. Callers MUST refuse a data table under roles outright — the Postgres
|
||||
/// trigger crate's `ensure_not_under_roles` — and turning roles on is refused while one streams.
|
||||
pub async fn get_datatable_replication_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
resolve_datatable_connection_unchecked(db, &governing, true).await
|
||||
}
|
||||
|
||||
/// The identity a resolution is made for. `Unchecked` is for callers that authorized already;
|
||||
/// everything else is checked against the governing entry's tenants.
|
||||
pub enum DatatableAccess<'a> {
|
||||
/// Reaches every role. For callers that already authorized, or that have no user at all.
|
||||
Unchecked,
|
||||
Authed(crate::db::AuthedRef<'a>),
|
||||
/// A job's owner, without reading the job row — only fetched if the data table turns out to
|
||||
/// be permissioned.
|
||||
PermissionedAs {
|
||||
permissioned_as: &'a str,
|
||||
email: &'a str,
|
||||
},
|
||||
/// A job identified by id; its owner is read from `v2_job`. For agent workers and anything
|
||||
/// else that authenticates as infrastructure rather than as the job's user.
|
||||
Job(uuid::Uuid),
|
||||
/// No identity established. Unpermissioned data tables resolve as before; permissioned ones
|
||||
/// are refused, so a caller predating this feature fails closed.
|
||||
NoIdentity,
|
||||
}
|
||||
|
||||
/// Does one tenant list cover this identity? Admins of the governing workspace pass everything —
|
||||
/// they can edit the tenant lists anyway, so refusing them would only be theatre.
|
||||
pub fn can_use_datatable_role(
|
||||
tenants: &DataTableRoleTenants,
|
||||
authed: &crate::db::AuthedRef<'_>,
|
||||
) -> bool {
|
||||
crate::datatable_roles_oss::can_use_datatable_role(tenants, authed)
|
||||
}
|
||||
|
||||
/// Evaluate a tenant list **as a member of the governing workspace**, whoever is calling.
|
||||
///
|
||||
/// A caller reaching a data table through a pointer is a member of some other workspace, and being
|
||||
/// its admin means nothing here — that is the whole point of the pointer. They are looked up in
|
||||
/// the governing workspace by email and evaluated there, or refused when they are not a member.
|
||||
/// A `g/` or `f/` permissioned-as from a foreign workspace is refused outright: those names are
|
||||
/// defined per workspace and mean nothing outside the one that defined them.
|
||||
pub async fn can_use_datatable_role_in_governing_workspace(
|
||||
db: &DB,
|
||||
governing_w_id: &str,
|
||||
w_id: &str,
|
||||
tenants: &DataTableRoleTenants,
|
||||
access: &DatatableAccess<'_>,
|
||||
) -> Result<bool> {
|
||||
crate::datatable_roles_oss::can_use_datatable_role_in_governing_workspace(
|
||||
db,
|
||||
governing_w_id,
|
||||
w_id,
|
||||
tenants,
|
||||
access,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a data table to connection credentials for one identity.
|
||||
///
|
||||
/// This is the chokepoint: everything that opens a connection to a data table on someone's behalf
|
||||
/// goes through it. `role` is the role name the caller asked for — the `-- role` annotation, the
|
||||
/// `?role=` on a `datatable://` reference, or `None` for the data table's default.
|
||||
///
|
||||
/// The resolved role **logs in as itself**. Never `SET ROLE`: a script could `RESET ROLE` its way
|
||||
/// back to admin.
|
||||
pub async fn get_datatable_resource_from_db(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
access: DatatableAccess<'_>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
let db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?;
|
||||
// Not under roles and asked for none, or for `admin` by name: the `admin` connection, as before
|
||||
// roles existed, in every edition. Anything else is a role decision. Every migration names
|
||||
// `admin` explicitly, so an edition without roles must not treat that as one.
|
||||
if governing.datatable.permissions.is_none() && role.is_none_or(|r| r == ADMIN_DATATABLE_ROLE) {
|
||||
return Ok(db_resource);
|
||||
}
|
||||
crate::datatable_roles_oss::resolve_datatable_role_connection(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
&governing,
|
||||
db_resource,
|
||||
role,
|
||||
access,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Would the chokepoint accept this identity connecting as this role? Answers without resolving
|
||||
/// credentials, for callers that want to refuse early and say which thing was refused.
|
||||
///
|
||||
/// Not the security boundary — [`get_datatable_resource_from_db`] re-checks when it actually opens
|
||||
/// the connection. This is what turns "permission denied for table x" into a message naming the
|
||||
/// migration and the role.
|
||||
pub async fn ensure_can_use_datatable_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
access: &DatatableAccess<'_>,
|
||||
context: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::ensure_can_use_datatable_role(db, w_id, name, role, access, context)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Gate the operations that see the whole database whatever the roles grant: a migration that
|
||||
/// declares no role, exports, and editing the permissions themselves. Not replication, which a
|
||||
/// data table under roles refuses whoever asks (see `ensure_not_under_roles`). Passing
|
||||
/// means the caller could have connected as `admin` anyway.
|
||||
pub async fn ensure_datatable_admin_access(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
access: &DatatableAccess<'_>,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::ensure_datatable_admin_access(db, w_id, name, access).await
|
||||
}
|
||||
|
||||
/// Rewrite the `permissions` of every data table entry of one workspace, in the caller's
|
||||
/// transaction. `change` reports whether it touched anything; the row is only written when
|
||||
/// something did.
|
||||
///
|
||||
/// Authorization: writes an access decision for any workspace named, with an arbitrary mutation,
|
||||
/// and checks nothing. It exists for the cascades below — the transaction that frees or renames a
|
||||
/// principal — so callers MUST be the operation that made the principal change, and MUST run in
|
||||
/// its transaction. Anything editing a decision on purpose belongs in the permissions endpoint,
|
||||
/// which is gated on the workspace that governs the data table.
|
||||
///
|
||||
/// The tenant lists name principals of this workspace, so anything that frees or renames one has
|
||||
/// to come through here in the same transaction that frees it — otherwise a `u/alice` reused by a
|
||||
/// later account silently inherits her access.
|
||||
pub async fn update_datatable_permissions_in_workspace<F>(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
change: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: Fn(&mut DataTablePermissions) -> bool,
|
||||
{
|
||||
let Some(mut settings) = sqlx::query_scalar!(
|
||||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
Ok(db_resource)
|
||||
let Some(datatables) = settings
|
||||
.get_mut("datatables")
|
||||
.and_then(|d| d.as_object_mut())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut touched = false;
|
||||
for entry in datatables.values_mut() {
|
||||
let Some(permissions) = entry.get("permissions").filter(|p| !p.is_null()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut permissions) =
|
||||
serde_json::from_value::<DataTablePermissions>(permissions.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if change(&mut permissions) {
|
||||
entry["permissions"] = serde_json::to_value(&permissions)
|
||||
.map_err(|e| Error::internal_err(format!("serializing permissions: {e}")))?;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if touched {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||||
settings,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a freed principal (`u/alice`, `g/analysts`, `f/finance`) from every tenant list of one
|
||||
/// workspace. Same contract as [`update_datatable_permissions_in_workspace`]: for the transaction
|
||||
/// that frees the principal, not for editing a decision.
|
||||
pub async fn remove_datatable_tenant_in_workspace(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
tenant: &str,
|
||||
) -> Result<()> {
|
||||
update_datatable_permissions_in_workspace(tx, w_id, |permissions| {
|
||||
let mut touched = false;
|
||||
for role in permissions.roles.values_mut() {
|
||||
let before = role.tenants.len();
|
||||
role.tenants.retain(|t| t != tenant);
|
||||
touched |= role.tenants.len() != before;
|
||||
}
|
||||
touched
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Follow a renamed principal through every tenant list of one workspace. Same contract as
|
||||
/// [`update_datatable_permissions_in_workspace`].
|
||||
pub async fn rename_datatable_tenant_in_workspace(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<()> {
|
||||
update_datatable_permissions_in_workspace(tx, w_id, |permissions| {
|
||||
let mut touched = false;
|
||||
for role in permissions.roles.values_mut() {
|
||||
let mut role_touched = false;
|
||||
for tenant in role.tenants.iter_mut() {
|
||||
if tenant == old {
|
||||
*tenant = new.to_string();
|
||||
role_touched = true;
|
||||
}
|
||||
}
|
||||
if role_touched {
|
||||
// The rename can collide with a name already in the list, and the two need not be
|
||||
// adjacent — `Vec::dedup` only collapses neighbours, so it would leave the pair.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
role.tenants.retain(|t| seen.insert(t.clone()));
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
touched
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Strip a deleted instance role from every workspace that had tenanted it, so nothing is left
|
||||
/// naming a role that no longer exists.
|
||||
///
|
||||
/// Authorization: reaches every workspace on the instance. Callers MUST be the superadmin path
|
||||
/// dropping the role from the cluster — it exists to follow that, not to edit tenants.
|
||||
///
|
||||
/// Takes that path's transaction rather than opening its own: run afterwards, a failure part-way
|
||||
/// leaves the catalog row already gone, so the retry answers `NotFound` while some workspaces
|
||||
/// still name a role nothing can connect as. In the transaction, the cluster drop, the catalog row
|
||||
/// and every tenant list commit together or not at all. A data table whose default role was the deleted one falls
|
||||
/// back to `admin` — the one role that is always present.
|
||||
pub async fn forget_datatable_role_everywhere(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
role_id: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::forget_datatable_role_everywhere(tx, role_id).await
|
||||
}
|
||||
|
||||
/// Drop the `permissions` block from a `workspace_settings.datatable` value before it leaves the
|
||||
/// server.
|
||||
///
|
||||
/// Who may connect as which role is an access decision, not configuration, and its tenants name
|
||||
/// principals of one workspace — `g/analysts` in dev is a different group from `g/analysts` in
|
||||
/// prod. Shipping it would both mean nothing at the far end and turn a settings push into a way to
|
||||
/// widen access, so the decision stays where it was made. [`DataTable`] deserializes fine without
|
||||
/// it, and the settings-editing endpoint carries the stored block across untouched.
|
||||
pub fn strip_datatable_permissions(
|
||||
datatable: Option<serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut datatable = datatable?;
|
||||
if let Some(entries) = datatable
|
||||
.get_mut("datatables")
|
||||
.and_then(|d| d.as_object_mut())
|
||||
{
|
||||
for entry in entries.values_mut() {
|
||||
if let Some(entry) = entry.as_object_mut() {
|
||||
entry.remove("permissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(datatable)
|
||||
}
|
||||
|
||||
/// As [`parse_datatable_ref`], except that an entry whose stored name itself contains `?` — which
|
||||
/// names could before they were restricted — resolves by that exact name, without a role. It is
|
||||
/// looked up first, so `sales?role=x` never reaches a different entry than the one stored so.
|
||||
/// When `sales` is stored too, the reference means either one, and is refused rather than
|
||||
/// resolved to whichever is looked up first.
|
||||
///
|
||||
/// Authorization: checks nothing, and its answer reveals whether `w_id` stores that exact name.
|
||||
/// Callers MUST already act for `w_id` — a job of it, or a caller authenticated into it — and
|
||||
/// MUST still pass the name to [`get_datatable_resource_from_db`] or an admin-access check.
|
||||
pub async fn parse_datatable_ref_for(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
reference: &str,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
if reference.contains('?') {
|
||||
let role_target = parse_datatable_ref(reference)
|
||||
.ok()
|
||||
.and_then(|(name, role)| role.map(|_| name));
|
||||
let (exists, target_exists) = sqlx::query_as::<_, (Option<bool>, Option<bool>)>(
|
||||
"SELECT (datatable->'datatables') ? $2, (datatable->'datatables') ? $3
|
||||
FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(reference)
|
||||
.bind(role_target)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or((None, None));
|
||||
if exists.unwrap_or(false) {
|
||||
if let (Some(name), Some(true)) = (role_target, target_exists) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{reference}' names both the data table '{reference}' \
|
||||
and a role on the data table '{name}'. Rename '{reference}' to use either."
|
||||
)));
|
||||
}
|
||||
return Ok((reference.to_string(), None));
|
||||
}
|
||||
}
|
||||
let (name, role) = parse_datatable_ref(reference)?;
|
||||
Ok((name.to_string(), role.map(str::to_string)))
|
||||
}
|
||||
|
||||
/// Split a `datatable://` reference into its name and the role its query string names.
|
||||
///
|
||||
/// A query string that does not parse is an error rather than an absent role. Falling back would
|
||||
/// resolve the reference to the data table's default role, so `?Role=analytics` or a mistyped
|
||||
/// `?role=` would quietly connect as something the caller did not ask for — the same trap as a
|
||||
/// malformed `-- role` annotation, and `role` is the only parameter a reference takes.
|
||||
pub fn parse_datatable_ref(reference: &str) -> Result<(&str, Option<&str>)> {
|
||||
let (name, query) = reference.split_once('?').unwrap_or((reference, ""));
|
||||
let mut role = None;
|
||||
for param in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (key, value) = param.split_once('=').unwrap_or((param, ""));
|
||||
if !key.eq_ignore_ascii_case("role") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' carries an unknown parameter '{key}'. \
|
||||
The only one it takes is `?role=<name>`."
|
||||
)));
|
||||
}
|
||||
if role.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' names a role more than once."
|
||||
)));
|
||||
}
|
||||
if value.is_empty() || !is_datatable_role_name(value) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' has a malformed role '{value}'. Write it as \
|
||||
`?role=<name>`, where <name> is letters, digits, '_' or '-'."
|
||||
)));
|
||||
}
|
||||
role = Some(value);
|
||||
}
|
||||
Ok((name, role))
|
||||
}
|
||||
|
||||
fn is_datatable_role_name(role: &str) -> bool {
|
||||
!role.is_empty()
|
||||
&& role.len() <= 63
|
||||
&& role
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
@@ -2642,6 +3322,130 @@ async fn transform_json_unchecked(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tenants(list: &[&str]) -> DataTableRoleTenants {
|
||||
DataTableRoleTenants { tenants: list.iter().map(|t| t.to_string()).collect() }
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[test]
|
||||
fn a_tenant_list_covers_users_groups_folders_and_the_wildcard() {
|
||||
let groups = vec!["analysts".to_string()];
|
||||
let folders = vec![("finance".to_string(), true, false)];
|
||||
let scopes = None;
|
||||
let token_prefix = None;
|
||||
let is_admin = false;
|
||||
let is_operator = false;
|
||||
let authed = crate::db::AuthedRef {
|
||||
email: "alice@windmill.dev",
|
||||
username: "alice",
|
||||
is_admin: &is_admin,
|
||||
is_operator: &is_operator,
|
||||
groups: &groups,
|
||||
folders: &folders,
|
||||
scopes: &scopes,
|
||||
token_prefix: &token_prefix,
|
||||
};
|
||||
|
||||
assert!(can_use_datatable_role(&tenants(&["u/alice"]), &authed));
|
||||
assert!(can_use_datatable_role(&tenants(&["g/analysts"]), &authed));
|
||||
assert!(can_use_datatable_role(&tenants(&["f/finance"]), &authed));
|
||||
assert!(can_use_datatable_role(&tenants(&["*"]), &authed));
|
||||
assert!(!can_use_datatable_role(&tenants(&[]), &authed));
|
||||
assert!(!can_use_datatable_role(
|
||||
&tenants(&["u/bob", "g/ops"]),
|
||||
&authed
|
||||
));
|
||||
// A bare name is not a principal: only the three prefixes and the wildcard match.
|
||||
assert!(!can_use_datatable_role(&tenants(&["alice"]), &authed));
|
||||
|
||||
// An admin of the governing workspace reaches every role: they can edit the lists anyway.
|
||||
let is_admin = true;
|
||||
let admin = crate::db::AuthedRef { is_admin: &is_admin, ..authed };
|
||||
assert!(can_use_datatable_role(&tenants(&[]), &admin));
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
#[test]
|
||||
fn without_the_enterprise_edition_no_tenant_list_covers_anyone() {
|
||||
let groups = vec![];
|
||||
let folders = vec![];
|
||||
let scopes = None;
|
||||
let token_prefix = None;
|
||||
let is_admin = true;
|
||||
let is_operator = false;
|
||||
let admin = crate::db::AuthedRef {
|
||||
email: "alice@windmill.dev",
|
||||
username: "alice",
|
||||
is_admin: &is_admin,
|
||||
is_operator: &is_operator,
|
||||
groups: &groups,
|
||||
folders: &folders,
|
||||
scopes: &scopes,
|
||||
token_prefix: &token_prefix,
|
||||
};
|
||||
assert!(!can_use_datatable_role(&tenants(&["*"]), &admin));
|
||||
assert!(!can_use_datatable_role(&tenants(&["u/alice"]), &admin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_datatable_ref_splits_off_its_role() {
|
||||
assert_eq!(parse_datatable_ref("sales").unwrap(), ("sales", None));
|
||||
assert_eq!(
|
||||
parse_datatable_ref("sales?role=analytics").unwrap(),
|
||||
("sales", Some("analytics"))
|
||||
);
|
||||
// The key matches case-insensitively, the way the `-- role` annotation does.
|
||||
assert_eq!(
|
||||
parse_datatable_ref("sales?Role=analytics").unwrap(),
|
||||
("sales", Some("analytics"))
|
||||
);
|
||||
|
||||
// A query string that does not parse is refused rather than read as "no role": resolving
|
||||
// it to the data table's default would connect as a login the caller never asked for.
|
||||
for malformed in [
|
||||
"sales?role=",
|
||||
"sales?role=an;alytics",
|
||||
"sales?x=1&role=analytics",
|
||||
"sales?role=a&role=b",
|
||||
] {
|
||||
assert!(
|
||||
parse_datatable_ref(malformed).is_err(),
|
||||
"silently ignored: {malformed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_owns_a_database_or_points_at_one_but_never_both() {
|
||||
let terminal = DataTable {
|
||||
database: Some(DataTableDatabase {
|
||||
resource_type: DataTableCatalogResourceType::Instance,
|
||||
resource_path: "dt_main".to_string(),
|
||||
}),
|
||||
reference: None,
|
||||
forked_from: None,
|
||||
migrations_enabled: None,
|
||||
permissions: None,
|
||||
};
|
||||
assert!(validate_datatable_shape("main", &terminal).is_ok());
|
||||
|
||||
let pointer = DataTable {
|
||||
database: None,
|
||||
reference: Some(DataTableReference {
|
||||
workspace_id: "prod".to_string(),
|
||||
datatable: "main".to_string(),
|
||||
}),
|
||||
..terminal.clone()
|
||||
};
|
||||
assert!(validate_datatable_shape("main", &pointer).is_ok());
|
||||
|
||||
let both = DataTable { database: terminal.database.clone(), ..pointer.clone() };
|
||||
assert!(validate_datatable_shape("main", &both).is_err());
|
||||
|
||||
let neither = DataTable { database: None, reference: None, ..terminal.clone() };
|
||||
assert!(validate_datatable_shape("main", &neither).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_fork_branch() {
|
||||
// Generated fork (`wm-fork-abc`) and dev workspace (`staging`) forms.
|
||||
|
||||
@@ -21,11 +21,12 @@ use windmill_common::{
|
||||
use windmill_git_sync::DeployedObject;
|
||||
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed};
|
||||
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
|
||||
use windmill_trigger::{Trigger, TriggerCrud, TriggerData, TriggerMode};
|
||||
|
||||
use super::{
|
||||
check_if_valid_publication_for_postgres_version, create_logical_replication_slot,
|
||||
create_pg_publication, drop_publication, generate_random_string, get_default_pg_connection,
|
||||
create_pg_publication, drop_publication, ensure_not_under_roles, generate_random_string,
|
||||
get_default_pg_connection,
|
||||
mapper::{Mapper, MappingInfo},
|
||||
PostgresConfig, PostgresConfigRequest, PostgresPublicationReplication, PostgresTrigger,
|
||||
PublicationData, Relations, Slot, SlotList, TableToTrack, TemplateScript, TestPostgresConfig,
|
||||
@@ -64,6 +65,29 @@ impl TriggerCrud for PostgresTrigger {
|
||||
DeployedObject::PostgresTrigger { path, parent_path }
|
||||
}
|
||||
|
||||
async fn validate_config(
|
||||
&self,
|
||||
db: &DB,
|
||||
config: &Self::TriggerConfigRequest,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
ensure_not_under_roles(db, workspace_id, &config.postgres_resource_path).await
|
||||
}
|
||||
|
||||
async fn authorize_set_trigger_mode(
|
||||
&self,
|
||||
_authed: &ApiAuthed,
|
||||
tx: &mut PgConnection,
|
||||
_workspace_id: &str,
|
||||
_path: &str,
|
||||
mode: &TriggerMode,
|
||||
) -> Result<()> {
|
||||
if *mode != TriggerMode::Disabled {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(tx, false).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_trigger(
|
||||
&self,
|
||||
db: &DB,
|
||||
@@ -72,6 +96,7 @@ impl TriggerCrud for PostgresTrigger {
|
||||
w_id: &str,
|
||||
trigger: TriggerData<Self::TriggerConfigRequest>,
|
||||
) -> Result<()> {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
||||
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
||||
let Self::TriggerConfigRequest {
|
||||
@@ -161,6 +186,7 @@ impl TriggerCrud for PostgresTrigger {
|
||||
path: &str,
|
||||
trigger: TriggerData<Self::TriggerConfigRequest>,
|
||||
) -> Result<()> {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
||||
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
||||
let Self::TriggerConfigRequest {
|
||||
|
||||
@@ -374,6 +374,35 @@ pub async fn get_raw_postgres_connection(
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// A replication stream reads every row of every table whatever the data table's roles grant, so
|
||||
/// the two don't mix: a data table under roles takes no triggers or captures, and roles cannot be
|
||||
/// turned on while one is enabled on it.
|
||||
///
|
||||
/// Authorization: checks nothing, and its refusal says whether `w_id`'s data table is under roles.
|
||||
/// Callers MUST have established that the caller may manage triggers in `w_id` first.
|
||||
pub(crate) async fn ensure_not_under_roles(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
postgres_resource_path: &str,
|
||||
) -> Result<()> {
|
||||
let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") else {
|
||||
return Ok(());
|
||||
};
|
||||
if windmill_common::workspaces::resolve_governing_datatable(db, w_id, datatable_name)
|
||||
.await?
|
||||
.datatable
|
||||
.permissions
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{datatable_name}' is under roles, and a Postgres trigger or capture \
|
||||
cannot read one: a replication stream sees every row whatever the roles grant. \
|
||||
Turn its roles off to stream it."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_postgres_resource(
|
||||
authed: &ApiAuthed,
|
||||
user_db: Option<UserDB>,
|
||||
@@ -382,6 +411,7 @@ pub async fn resolve_postgres_resource(
|
||||
w_id: &str,
|
||||
) -> Result<Postgres> {
|
||||
if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") {
|
||||
ensure_not_under_roles(db, w_id, postgres_resource_path).await?;
|
||||
// Trigger connections (publication/slot management + logical replication) run
|
||||
// as the dedicated replication user on custom-instance databases.
|
||||
let resource_value =
|
||||
|
||||
@@ -20,7 +20,8 @@ use windmill_common::{
|
||||
use windmill_trigger::{listener::ListeningTrigger, trigger_helpers::TriggerJobArgs, Listener};
|
||||
|
||||
use super::{
|
||||
drop_publication, get_default_pg_connection, get_raw_postgres_connection,
|
||||
drop_publication, ensure_not_under_roles, get_default_pg_connection,
|
||||
get_raw_postgres_connection,
|
||||
handler::drop_logical_replication_slot,
|
||||
relation::RelationConverter,
|
||||
replication_message::{
|
||||
@@ -135,8 +136,8 @@ impl PostgresSimpleClient {
|
||||
/// Resolves the Postgres resource, validates that the configured publication and
|
||||
/// replication slot still exist, and opens a fresh logical replication stream.
|
||||
///
|
||||
/// Returns `Error::BadConfig` when the publication or slot is missing (an
|
||||
/// unrecoverable misconfiguration). Any other error is treated as transient
|
||||
/// Returns `Error::BadConfig` when the publication or slot is missing, or the
|
||||
/// data table is under roles (unrecoverable misconfigurations). Any other error is treated as transient
|
||||
/// (connection refused, network interruption, ...) and is retried by the caller.
|
||||
/// The resource is re-resolved on every call so credential rotations are picked
|
||||
/// up across reconnections.
|
||||
@@ -149,6 +150,14 @@ async fn connect_logical_replication_stream(
|
||||
let PostgresConfig { postgres_resource_path, publication_name, replication_slot_name, .. } =
|
||||
trigger_config;
|
||||
|
||||
// Retrying cannot lift roles, so this disables the trigger like a missing slot does.
|
||||
ensure_not_under_roles(db, workspace_id, postgres_resource_path)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::BadRequest(msg) => Error::BadConfig(msg),
|
||||
e => e,
|
||||
})?;
|
||||
|
||||
let database = resolve_postgres_resource(
|
||||
authed,
|
||||
Some(UserDB::new(db.clone())),
|
||||
|
||||
@@ -64,16 +64,25 @@ pub async fn get_ducklake_from_agent_http(
|
||||
.await
|
||||
}
|
||||
|
||||
/// An agent worker authenticates as infrastructure, not as the job's user, so the job id travels
|
||||
/// with the request: the server reads the job's owner from it and evaluates the data table's
|
||||
/// tenants against them. A worker predating this sends neither, and the server fails it closed on
|
||||
/// a data table under roles.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_datatable_resource_from_agent_http(
|
||||
client: &HttpClient,
|
||||
name: &str,
|
||||
w_id: &str,
|
||||
role: Option<&str>,
|
||||
job_id: &uuid::Uuid,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let role_query = role
|
||||
.map(|r| format!("&role={}", urlencoding::encode(r)))
|
||||
.unwrap_or_default();
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/w/{}/agent_workers/get_datatable_resource/{}",
|
||||
w_id, &name
|
||||
"/api/w/{}/agent_workers/get_datatable_resource/{}?job_id={}{}",
|
||||
w_id, &name, job_id, role_query
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::utils::sanitize_string_from_password;
|
||||
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked,
|
||||
strip_fork_reserved_attach_args, DucklakeCatalogResourceType,
|
||||
get_datatable_resource_from_db, get_ducklake_from_db_unchecked,
|
||||
strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType,
|
||||
};
|
||||
use windmill_common::PgDatabase;
|
||||
use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE;
|
||||
@@ -1494,13 +1494,9 @@ pub async fn do_duckdb(
|
||||
.await?
|
||||
{
|
||||
probe_blocks.extend(q);
|
||||
} else if let Some(q) = transform_attach_datatable(
|
||||
&query_block,
|
||||
conn,
|
||||
&mut hidden_passwords,
|
||||
&job.workspace_id,
|
||||
)
|
||||
.await?
|
||||
} else if let Some(q) =
|
||||
transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job)
|
||||
.await?
|
||||
{
|
||||
probe_blocks.extend(q);
|
||||
} else {
|
||||
@@ -1575,13 +1571,9 @@ pub async fn do_duckdb(
|
||||
.await?
|
||||
{
|
||||
v.extend(ducklake_query);
|
||||
} else if let Some(datatable_query) = transform_attach_datatable(
|
||||
&query_block,
|
||||
conn,
|
||||
&mut hidden_passwords,
|
||||
&job.workspace_id,
|
||||
)
|
||||
.await?
|
||||
} else if let Some(datatable_query) =
|
||||
transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job)
|
||||
.await?
|
||||
{
|
||||
v.extend(datatable_query);
|
||||
} else {
|
||||
@@ -2609,33 +2601,79 @@ fn fork_defer_statements(
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
struct AttachedDatatable<'a> {
|
||||
/// The data table reference, query string included; a bare `datatable` is `main`.
|
||||
reference: String,
|
||||
alias: &'a str,
|
||||
}
|
||||
|
||||
/// `ATTACH 'datatable[://<name>][?role=<role>]' AS <alias>`. A bare `datatable` names the default
|
||||
/// data table, so the role query string has to be accepted with and without an explicit name. The
|
||||
/// reference is split only once the workspace can be read, because a stored name may contain `?`.
|
||||
fn parse_attach_datatable(query: &str) -> Option<AttachedDatatable<'_>> {
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE: regex::Regex = regex::Regex::new(
|
||||
r"(?i)ATTACH\s*'datatable(://[^':]+|\?[^':]*)?'\s*AS\s+([^ ;]+)"
|
||||
).unwrap();
|
||||
}
|
||||
let cap = RE.captures(query)?;
|
||||
let reference = match cap.get(1).map(|m| m.as_str()) {
|
||||
Some(named) if named.starts_with("://") => named[3..].to_string(),
|
||||
Some(query) => format!("main{query}"),
|
||||
None => "main".to_string(),
|
||||
};
|
||||
let alias = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
||||
Some(AttachedDatatable { reference, alias })
|
||||
}
|
||||
|
||||
async fn transform_attach_datatable(
|
||||
query: &str,
|
||||
conn: &Connection,
|
||||
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
||||
w_id: &str,
|
||||
job: &MiniPulledJob,
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap();
|
||||
}
|
||||
let Some(cap) = RE.captures(query) else {
|
||||
let Some(attached) = parse_attach_datatable(query) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main");
|
||||
let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
// A query string that does not parse is refused rather than dropped: attaching under the
|
||||
// default role when the statement asked for another one is the failure this guards.
|
||||
let db_resource = match conn {
|
||||
Connection::Http(client) => {
|
||||
get_datatable_resource_from_agent_http(client, name, w_id).await?
|
||||
let (name, role) =
|
||||
windmill_common::workspaces::parse_datatable_ref(&attached.reference)?;
|
||||
get_datatable_resource_from_agent_http(client, name, &job.workspace_id, role, &job.id)
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
let (name, role) = windmill_common::workspaces::parse_datatable_ref_for(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&attached.reference,
|
||||
)
|
||||
.await?;
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&name,
|
||||
role.as_deref(),
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?,
|
||||
};
|
||||
|
||||
if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) {
|
||||
hidden_passwords.lock().unwrap().push(pwd.to_string());
|
||||
}
|
||||
|
||||
Ok(Some(pg_secret_attach_statements(db_resource, alias_name)?))
|
||||
Ok(Some(pg_secret_attach_statements(
|
||||
db_resource,
|
||||
attached.alias,
|
||||
)?))
|
||||
}
|
||||
|
||||
// Secret names must be plain identifiers; the hash keeps two aliases distinct even
|
||||
@@ -2680,6 +2718,11 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result<V
|
||||
esc(res.password.as_deref().unwrap_or("")),
|
||||
),
|
||||
format!("ATTACH 'sslmode={sslmode}' AS {alias_name} (TYPE postgres, SECRET {secret_name});"),
|
||||
// The attachment keeps its own resolved connection string, so the secret is dead weight
|
||||
// once attached — and a live one is a credential the script's own statements can name: an
|
||||
// `ATTACH 'dbname=<other>' (TYPE postgres, SECRET …)` would reach a database nobody
|
||||
// authorized this job for, as this role.
|
||||
format!("DROP TEMPORARY SECRET {secret_name};"),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2753,6 +2796,45 @@ pub struct Arg {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attach_datatable_parses_name_and_role() {
|
||||
let reference_of = |q: &str| parse_attach_datatable(q).unwrap().reference;
|
||||
let named =
|
||||
parse_attach_datatable("ATTACH 'datatable://sales?role=analytics' AS dt").unwrap();
|
||||
assert_eq!(
|
||||
(named.reference.as_str(), named.alias),
|
||||
("sales?role=analytics", "dt")
|
||||
);
|
||||
// A bare `datatable` is the default one, and still takes a role.
|
||||
assert_eq!(
|
||||
reference_of("ATTACH 'datatable?role=analytics' AS dt"),
|
||||
"main?role=analytics"
|
||||
);
|
||||
assert_eq!(reference_of("ATTACH 'datatable://sales' AS dt"), "sales");
|
||||
assert_eq!(reference_of("ATTACH 'datatable' AS dt"), "main");
|
||||
assert!(parse_attach_datatable("SELECT 1").is_none());
|
||||
// A stored name can contain `?`, so that is left to the workspace lookup to split.
|
||||
assert_eq!(reference_of("ATTACH 'datatable://a?b' AS dt"), "a?b");
|
||||
|
||||
// The key matches case-insensitively, as the `-- role` annotation does, and a query string
|
||||
// that does not parse is refused rather than attached under the default role.
|
||||
let parse = |q: &str| {
|
||||
windmill_common::workspaces::parse_datatable_ref(&reference_of(q))
|
||||
.map(|(name, role)| (name.to_string(), role.map(str::to_string)))
|
||||
};
|
||||
assert_eq!(
|
||||
parse("ATTACH 'datatable://sales?Role=analytics' AS dt").unwrap(),
|
||||
("sales".to_string(), Some("analytics".to_string()))
|
||||
);
|
||||
for malformed in [
|
||||
"ATTACH 'datatable://sales?role=' AS dt",
|
||||
"ATTACH 'datatable://sales?role=an;alytics' AS dt",
|
||||
"ATTACH 'datatable://sales?x=1&role=analytics' AS dt",
|
||||
] {
|
||||
assert!(parse(malformed).is_err(), "silently ignored: {malformed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ffi_error_unescapes_multiline_and_strips_quotes() {
|
||||
// Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ".
|
||||
@@ -3868,6 +3950,8 @@ mod tests {
|
||||
stmts[3],
|
||||
format!("ATTACH 'sslmode=require' AS dt (TYPE postgres, SECRET {secret_name});")
|
||||
);
|
||||
assert_eq!(stmts[4], format!("DROP TEMPORARY SECRET {secret_name};"));
|
||||
assert_eq!(stmts.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,9 +26,11 @@ use windmill_common::azure_workload_identity::WORKLOAD_IDENTITY_PASSWORD;
|
||||
use windmill_common::error::to_anyhow;
|
||||
use windmill_common::error::{self, Error};
|
||||
use windmill_common::worker::{
|
||||
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
to_raw_value, Connection, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess,
|
||||
};
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||||
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
|
||||
use windmill_parser::{Arg, Typ};
|
||||
use windmill_parser_sql::{
|
||||
@@ -680,15 +682,36 @@ pub async fn do_postgresql(
|
||||
} else {
|
||||
match pg_args.get("database").cloned() {
|
||||
Some(Value::String(db_str)) if db_str.starts_with("datatable://") => {
|
||||
let db_str = db_str.trim_start_matches("datatable://");
|
||||
let reference = db_str.trim_start_matches("datatable://");
|
||||
// The annotation wins: a generated query can carry a `?role=` in the reference it
|
||||
// was handed, but only the script's author writes the leading comment block.
|
||||
let annotated = SqlAnnotations::datatable_role(&query)?;
|
||||
Some(match conn {
|
||||
Connection::Http(client) => {
|
||||
get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id)
|
||||
.await?
|
||||
let (name, uri_role) = parse_datatable_ref(reference)?;
|
||||
get_datatable_resource_from_agent_http(
|
||||
client,
|
||||
name,
|
||||
&job.workspace_id,
|
||||
annotated.as_deref().or(uri_role),
|
||||
&job.id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str)
|
||||
.await?
|
||||
let (name, uri_role) =
|
||||
parse_datatable_ref_for(db, &job.workspace_id, reference).await?;
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&name,
|
||||
annotated.as_deref().or(uri_role.as_deref()),
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface AppFile {
|
||||
tables?: string[];
|
||||
datatable?: string;
|
||||
schema?: string;
|
||||
/** The role the app uses each data table through, by data table name. */
|
||||
roles?: Record<string, string>;
|
||||
};
|
||||
// Mirrors granular ACLs on the raw_app path. Synced via /acls/* by
|
||||
// applyExtraPermsDiff — never through update_app_raw — so a perm-only
|
||||
|
||||
@@ -393,10 +393,17 @@ export async function pushWorkspaceSettings(
|
||||
|
||||
if (!deepEqual(localSettings.datatable, settings.datatable)) {
|
||||
log.debug(`Updating datatable config...`);
|
||||
await wmill.editDataTableConfig({
|
||||
const { stranded_references } = await wmill.editDataTableConfig({
|
||||
workspace,
|
||||
requestBody: { settings: localSettings.datatable ?? { datatables: {} } },
|
||||
});
|
||||
if (stranded_references?.length) {
|
||||
log.warn(
|
||||
`Removed data tables governed data tables in other workspaces, which no longer resolve: ${stranded_references
|
||||
.map((r) => `${r.workspace_id}/${r.datatable}`)
|
||||
.join(", ")}. A superadmin can point them somewhere else.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (localSettings.slack_command_script != settings.slack_command_script) {
|
||||
|
||||
Generated
+30
-8
@@ -1110,6 +1110,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1119,8 +1121,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
*/
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -1901,6 +1906,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1910,8 +1917,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
*/
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -2786,6 +2796,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -2795,8 +2807,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
*/
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -4405,10 +4420,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
#
|
||||
# Args:
|
||||
# name: Database name (default: "main")
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main')
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
@@ -4626,7 +4644,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
|
||||
#
|
||||
# @task(retry={"attempts": 3, "delay": 30, "multiplier": 2})
|
||||
# async def call_api(payload: dict): ...
|
||||
def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
|
||||
# Create a task that dispatches to a separate Windmill script.
|
||||
#
|
||||
@@ -4639,7 +4657,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti
|
||||
# @workflow
|
||||
# async def main():
|
||||
# data = await extract(url="https://...")
|
||||
def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
|
||||
# Create a task that dispatches to a separate Windmill flow.
|
||||
#
|
||||
@@ -4652,7 +4670,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N
|
||||
# @workflow
|
||||
# async def main():
|
||||
# result = await pipeline(input=data)
|
||||
def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
|
||||
# Decorator marking an async function as a workflow-as-code entry point.
|
||||
#
|
||||
@@ -4717,7 +4735,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_
|
||||
# ...
|
||||
#
|
||||
# results = await parallel(items, process, concurrency=5)
|
||||
async def parallel(items, fn, concurrency: Optional[int] = None)
|
||||
async def parallel(items, fn, *, concurrency: Optional[int] = None)
|
||||
|
||||
# Commit Kafka offsets for a trigger with auto_commit disabled.
|
||||
#
|
||||
@@ -5808,6 +5826,8 @@ data:
|
||||
tables:
|
||||
- main/users # Table in public schema
|
||||
- main/app_schema:items # Table in specific schema
|
||||
roles: # Optional: the role the app uses each datatable through
|
||||
main: analyst
|
||||
\`\`\`
|
||||
|
||||
**Table reference formats:**
|
||||
@@ -5815,6 +5835,8 @@ data:
|
||||
- \`<datatable>/<table>\` — Specific table in public schema
|
||||
- \`<datatable>/<schema>:<table>\` — Table in specific schema
|
||||
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
The \`sql_to_apply/\` folder is for creating/modifying database tables during development.
|
||||
|
||||
@@ -65,7 +65,10 @@
|
||||
|
||||
# Misc
|
||||
libtool
|
||||
postgresql
|
||||
# Must not trail the server the dev database runs (postgres:18): pg_dump refuses a
|
||||
# server newer than itself by a major version, which takes out every data table
|
||||
# export, clone and fork-with-data.
|
||||
postgresql_18
|
||||
|
||||
# Build tooling
|
||||
pkg-config
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
|
||||
import type { DataTableTables } from '$lib/gen'
|
||||
import { sortArray } from '$lib/utils'
|
||||
import { Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { dbSupportsSchemas } from './apps/components/display/dbtable/utils'
|
||||
import {
|
||||
dbSupportsSchemas,
|
||||
getLanguageByResourceType
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import DbManager from './DBManager.svelte'
|
||||
import DbWorkerTagPicker from './DbWorkerTagPicker.svelte'
|
||||
import MissingWorkerTagAlert from './jobs/MissingWorkerTagAlert.svelte'
|
||||
import {
|
||||
dbSchemaOpsWithPreviewScripts,
|
||||
dbTableOpsWithPreviewScripts,
|
||||
getDatabaseArg,
|
||||
getDbType,
|
||||
getDefaultDbTag,
|
||||
getDucklakeSchema
|
||||
@@ -18,11 +23,11 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SqlRepl from './SqlRepl.svelte'
|
||||
import SimpleAgTable from './SimpleAgTable.svelte'
|
||||
import { type Snippet } from 'svelte'
|
||||
import type { DbInput } from './dbTypes'
|
||||
import type { DatatableRowAction, DbInput } from './dbTypes'
|
||||
import { schemaCacheKey } from './dbSchemaCache'
|
||||
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
|
||||
|
||||
import type { SelectedTable } from './DBManager.svelte'
|
||||
import type { PendingRowAction, SelectedTable } from './DBManager.svelte'
|
||||
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
|
||||
import { resource } from 'runed'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
@@ -36,7 +41,15 @@
|
||||
hasReplResult?: boolean
|
||||
selectedSchemaKey?: string | undefined
|
||||
selectedTableKey?: string | undefined
|
||||
dbSelector?: Snippet<[]>
|
||||
/** Every data table with its schemas and tables, for the left-pane tree. Undefined when
|
||||
* the manager is not on a data table, which drops the tree's top level. */
|
||||
datatableTree?: DataTableTables[]
|
||||
datatableTreeLoading?: boolean
|
||||
onSelectDatatable?: (datatable: string) => void
|
||||
onSelectRole?: (datatable: string, role: string) => void
|
||||
pendingAction?: PendingRowAction | undefined
|
||||
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
|
||||
canManageDatatable?: boolean
|
||||
/** Enable multi-select mode with checkboxes in sidebar */
|
||||
multiSelectMode?: boolean
|
||||
/** Selected tables in multi-select mode */
|
||||
@@ -59,7 +72,13 @@
|
||||
hasReplResult = $bindable(false),
|
||||
selectedSchemaKey = $bindable(undefined),
|
||||
selectedTableKey = $bindable(undefined),
|
||||
dbSelector,
|
||||
datatableTree,
|
||||
datatableTreeLoading,
|
||||
onSelectDatatable,
|
||||
onSelectRole,
|
||||
pendingAction = $bindable(),
|
||||
onDatatableAction,
|
||||
canManageDatatable,
|
||||
multiSelectMode = false,
|
||||
selectedTables = $bindable([]),
|
||||
disabledTables = [],
|
||||
@@ -70,33 +89,25 @@
|
||||
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
|
||||
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(input)])
|
||||
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(ws, input)])
|
||||
|
||||
const outOfOrderModal = createAsyncConfirmationModal()
|
||||
|
||||
function getDbSchemasPath(input: DbInput): string {
|
||||
switch (input.type) {
|
||||
case 'database':
|
||||
return input.resourcePath
|
||||
case 'ducklake':
|
||||
return 'ducklake://' + input.ducklake
|
||||
}
|
||||
}
|
||||
|
||||
// Scope the shared `dbSchemas` cache by the acting workspace: a datatable of
|
||||
// the same name can exist in both the nav and the acting workspace, so the
|
||||
// bare resource path alone would let one workspace's schema be reused for the
|
||||
// other while DB operations target the acting one.
|
||||
function schemaCacheKey(input: DbInput): string {
|
||||
return `${ws}:${getDbSchemasPath(input)}`
|
||||
}
|
||||
|
||||
// Reported in place of the loading spinner: both queries run as jobs, so
|
||||
// anything from a bad connection to a tag no worker serves surfaces here
|
||||
// instead of leaving the manager spinning with no explanation. Each query
|
||||
// owns its slot so neither can clear the other's error on a refetch.
|
||||
let schemaError = $state<string | undefined>(undefined)
|
||||
let colDefsError = $state<string | undefined>(undefined)
|
||||
function emptySchemaFor(db: DbInput): DBSchema {
|
||||
return {
|
||||
lang: db.type === 'ducklake' ? 'ducklake' : getLanguageByResourceType(db.resourceType),
|
||||
schema: {},
|
||||
publicOnly: undefined,
|
||||
stringified: ''
|
||||
} as DBSchema
|
||||
}
|
||||
|
||||
let loadError = $derived(
|
||||
schemaError
|
||||
? { title: 'Could not load the database schema', message: schemaError }
|
||||
@@ -136,14 +147,23 @@
|
||||
const run = ++schemaRun
|
||||
schemaError = undefined
|
||||
if (!input) return
|
||||
const dbSchemasPath = schemaCacheKey(input)
|
||||
const dbSchemasPath = schemaCacheKey(ws, input)
|
||||
if (input.type == 'database') {
|
||||
let connection = input.resourcePath
|
||||
try {
|
||||
// The role'd reference, validated: an invalid role fails here rather than
|
||||
// reading the schema as the data table's default role.
|
||||
if (connection.startsWith('datatable://')) connection = getDatabaseArg(input).database!
|
||||
} catch (e) {
|
||||
schemaError = (e as Error)?.message ?? String(e)
|
||||
return
|
||||
}
|
||||
// Reported through a local, not `schemaError` directly, so a superseded
|
||||
// run's callback can't fail a load that already succeeded.
|
||||
let queryError: string | undefined
|
||||
const schema = await getDbSchemas(
|
||||
input.resourceType,
|
||||
input.resourcePath,
|
||||
connection,
|
||||
ws,
|
||||
(message: string) => (queryError = message),
|
||||
{ customTag: workerTag }
|
||||
@@ -223,17 +243,23 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- The error branch comes first on purpose: `dbSchema` is read from a cache that
|
||||
survives a failed refetch, so ordering it first would hide the failure behind
|
||||
stale content. -->
|
||||
{#if loadError}
|
||||
<!-- A load error replaces only the data pane: the tree, its role badge and menus, and the REPL
|
||||
stay usable, so another data table or role can be picked and the connection tried by hand.
|
||||
The tree then gets an empty schema: the cached one survives a failed refetch and would pass
|
||||
stale content off as what this connection reaches. -->
|
||||
{#snippet errorPane()}
|
||||
<div class="h-full w-full flex flex-col items-center justify-center gap-3 p-8">
|
||||
<div class="max-w-2xl w-full flex flex-col gap-3">
|
||||
<Alert type="error" title={loadError.title} size="xs">
|
||||
{loadError.message}
|
||||
<Alert type="error" title={loadError?.title ?? ''} size="xs">
|
||||
{loadError?.message}
|
||||
</Alert>
|
||||
<div class="self-start">
|
||||
<Button size="xs" color="light" startIcon={{ icon: RefreshCcw }} on:click={() => refresh()}>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: RefreshCcw }}
|
||||
on:click={() => refresh()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -247,9 +273,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if dbSchema && ws && input}
|
||||
{/snippet}
|
||||
|
||||
{#if (loadError || dbSchema) && ws && input}
|
||||
{@const _input = input}
|
||||
{@const dbType = getDbType(_input)}
|
||||
{@const shownSchema = loadError || !dbSchema ? emptySchemaFor(_input) : dbSchema}
|
||||
<Splitpanes horizontal>
|
||||
<Pane class="relative">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -274,9 +303,11 @@
|
||||
</div>
|
||||
<DbManager
|
||||
dbSupportsSchemas={dbSupportsSchemas(dbType)}
|
||||
databaseIsEmpty={!Object.values(dbSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
{dbSchema}
|
||||
colDefs={colDefs.current}
|
||||
databaseIsEmpty={!loadError &&
|
||||
!Object.values(shownSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
dbSchema={shownSchema}
|
||||
mainPane={loadError ? errorPane : undefined}
|
||||
colDefs={loadError ? undefined : colDefs.current}
|
||||
dbTableOpsFactory={({ colDefs, tableKey, whereClause }) =>
|
||||
dbTableOpsWithPreviewScripts({
|
||||
colDefs,
|
||||
@@ -306,7 +337,15 @@
|
||||
: undefined}
|
||||
{dbType}
|
||||
refresh={() => refresh()}
|
||||
{dbSelector}
|
||||
{datatableTree}
|
||||
{datatableTreeLoading}
|
||||
{onSelectDatatable}
|
||||
{onSelectRole}
|
||||
workspace={ws}
|
||||
currentRole={input.type === 'database' ? input.role : undefined}
|
||||
bind:pendingAction
|
||||
{onDatatableAction}
|
||||
{canManageDatatable}
|
||||
{onImport}
|
||||
bind:selectedSchemaKey
|
||||
bind:selectedTableKey
|
||||
@@ -329,12 +368,12 @@
|
||||
onSchemaChange={() => refresh()}
|
||||
placeholderTableName={sortArray(
|
||||
Object.keys(
|
||||
dbSchema?.schema[
|
||||
'public' in dbSchema?.schema
|
||||
shownSchema.schema[
|
||||
'public' in shownSchema.schema
|
||||
? 'public'
|
||||
: 'dbo' in dbSchema?.schema
|
||||
: 'dbo' in shownSchema.schema
|
||||
? 'dbo'
|
||||
: Object.keys(dbSchema?.schema ?? {})?.[0]
|
||||
: Object.keys(shownSchema.schema ?? {})?.[0]
|
||||
] ?? {}
|
||||
)
|
||||
)?.[0]}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { enterpriseLicense, superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService, type DataTableTables } from '$lib/gen'
|
||||
import { listUsableDatatableRoles } from './datatableUsableRoles'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import Select from './select/Select.svelte'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
Expand,
|
||||
LoaderCircle,
|
||||
Minimize,
|
||||
RefreshCcw,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import { ArrowLeft, Copy, Download, Expand, Minimize, RefreshCcw, Upload } from 'lucide-svelte'
|
||||
import DBManagerContent from './DBManagerContent.svelte'
|
||||
import type { PendingRowAction } from './DBManager.svelte'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
|
||||
import {
|
||||
ADMIN_DATATABLE_ROLE,
|
||||
datatableNameTakesRole,
|
||||
defaultMigrationRole,
|
||||
type DatatableRowAction
|
||||
} from './dbTypes'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -41,31 +41,113 @@
|
||||
// the editor that opened it (set via openDrawer), else the nav workspace.
|
||||
let ws = $derived(uriState.workspace ?? $workspaceStore)
|
||||
|
||||
// Load available datatables when drawer opens with datatable input
|
||||
const datatables = resource<string[]>([], async () => {
|
||||
if (!ws) return []
|
||||
try {
|
||||
return (await WorkspaceService.listDataTables({ workspace: ws })).map((d) => d.name)
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return []
|
||||
}
|
||||
})
|
||||
// A create started on a data table other than the current one: survives the
|
||||
// re-mount the switch causes.
|
||||
let pendingAction = $state<PendingRowAction | undefined>(undefined)
|
||||
|
||||
const datatableItems = $derived(
|
||||
datatables.current.map((dt) => ({
|
||||
value: dt,
|
||||
label: dt
|
||||
}))
|
||||
// Read once through primitives: the getters return values of a freshly parsed URL, which
|
||||
// changes on every table click, and the listings below must not refetch for that.
|
||||
const selectedDatatable = $derived(uriState.selectedDatatable)
|
||||
const selectedRole = $derived(uriState.selectedRole)
|
||||
|
||||
// Roles the caller may use, to settle the role before anything connects. Offering only
|
||||
// these is a convenience: the server refuses any other.
|
||||
const usableRoles = resource(
|
||||
() => [ws, selectedDatatable] as const,
|
||||
async ([workspace, datatable]) => {
|
||||
if (!workspace || !datatable) return undefined
|
||||
try {
|
||||
return {
|
||||
datatable,
|
||||
...(await listUsableDatatableRoles(workspace, datatable))
|
||||
}
|
||||
} catch (e) {
|
||||
// Never leave the drawer waiting on this: fall back to the
|
||||
// unpermissioned shape so it opens and the server picks the role.
|
||||
console.error('Failed to load datatable roles:', e)
|
||||
return { datatable, permissioned: false, roles: [], default_role: ADMIN_DATATABLE_ROLE }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Refetch datatables when switching to a datatable input
|
||||
// A resource keeps its previous value while refetching, and roles are per data
|
||||
// table: settling from the last one's answer would connect to the new data
|
||||
// table as a role it may not even have.
|
||||
const rolesOfCurrent = $derived(
|
||||
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
|
||||
)
|
||||
|
||||
// Nothing that connects runs until the role is settled: a first round sent without a role
|
||||
// would run — and cache — as whatever the server defaults to.
|
||||
const roleSettled = $derived(
|
||||
!uriState.isDatatableInput ||
|
||||
(rolesOfCurrent !== undefined &&
|
||||
(!rolesOfCurrent.permissioned ||
|
||||
rolesOfCurrent.roles.length === 0 ||
|
||||
selectedRole !== undefined ||
|
||||
// Its reference cannot name a role, so it connects as the default one.
|
||||
(selectedDatatable !== undefined && !datatableNameTakesRole(selectedDatatable))))
|
||||
)
|
||||
|
||||
// Make the role explicit before anything queries the data table, so the URL, the
|
||||
// cache and every migration the manager writes name it. A role already in the URL
|
||||
// is kept even when it is not usable: the server refuses it, visibly.
|
||||
$effect(() => {
|
||||
if (uriState.isDatatableInput) {
|
||||
untrack(() => datatables.refetch())
|
||||
}
|
||||
const roles = rolesOfCurrent
|
||||
if (
|
||||
!roles?.permissioned ||
|
||||
selectedRole !== undefined ||
|
||||
(selectedDatatable !== undefined && !datatableNameTakesRole(selectedDatatable))
|
||||
)
|
||||
return
|
||||
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
|
||||
if (effective) untrack(() => (uriState.selectedRole = effective))
|
||||
})
|
||||
|
||||
const contentInput = $derived.by(() => {
|
||||
const input = uriState.effectiveInput
|
||||
if (input?.type !== 'database' || selectedDatatable === undefined) return input
|
||||
const migrationRole = defaultMigrationRole(
|
||||
selectedDatatable,
|
||||
rolesOfCurrent?.permissioned,
|
||||
rolesOfCurrent?.default_role
|
||||
)
|
||||
return migrationRole === undefined ? input : { ...input, migrationRole }
|
||||
})
|
||||
|
||||
// Every data table with its schemas and tables, in one call: this is what the
|
||||
// left pane's tree navigates, so it has to cover the data tables the user is
|
||||
// not currently on, not just the selected one. The privileges it reports are
|
||||
// the connected role's, so the role picked on the open data table is part of
|
||||
// what is being asked. Gated on the drawer being open on a data table: this
|
||||
// reaches every data table's database in turn, and the component is mounted on
|
||||
// every logged-in page.
|
||||
let datatablesRun = 0
|
||||
const datatables = resource(
|
||||
() =>
|
||||
[
|
||||
open && uriState.isDatatableInput,
|
||||
ws,
|
||||
selectedDatatable,
|
||||
selectedRole,
|
||||
roleSettled
|
||||
] as const,
|
||||
async ([active, workspace, roleFor, role, settled]): Promise<DataTableTables[]> => {
|
||||
if (!active || !workspace) return []
|
||||
if (!settled) return untrack(() => datatables.current)
|
||||
const run = ++datatablesRun
|
||||
try {
|
||||
const result = await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
|
||||
// An answer for a selection that has since changed describes another role.
|
||||
return run === datatablesRun ? result : untrack(() => datatables.current)
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return run === datatablesRun ? [] : untrack(() => datatables.current)
|
||||
}
|
||||
},
|
||||
{ initialValue: [] }
|
||||
)
|
||||
|
||||
function handleClose() {
|
||||
uriState.closeDrawer()
|
||||
dbManagerContent?.clearReplResult()
|
||||
@@ -78,6 +160,10 @@
|
||||
if (!open) {
|
||||
expand = false
|
||||
uriState.closeDrawer()
|
||||
// An action asked for on one data table must not be waiting when the
|
||||
// drawer is next opened on another database — or on no data table at
|
||||
// all, where nothing would recognise it as foreign.
|
||||
pendingAction = undefined
|
||||
}
|
||||
})
|
||||
|
||||
@@ -97,6 +183,8 @@
|
||||
let importDrawerOpen = $state(false)
|
||||
let importLoading = $state(false)
|
||||
let importSource = $state<string | undefined>(undefined)
|
||||
/** Which database an import writes into; set when driven from a tree row. */
|
||||
let importTarget = $state<string | undefined>(undefined)
|
||||
let importBehavior = $state<'schema_only' | 'schema_and_data'>('schema_only')
|
||||
|
||||
let isPostgresqlInput = $derived(
|
||||
@@ -116,13 +204,49 @@
|
||||
return toSourceIdentifier(input.resourcePath)
|
||||
}
|
||||
|
||||
// The tree's row menus act on the data table of the row that was clicked, which
|
||||
// is not necessarily the one currently open — so the target is set first and the
|
||||
// headless modals are keyed on it.
|
||||
let actionDatatable = $state<string | undefined>(undefined)
|
||||
let migrationsModal = $state<DataTableMigrationsButton | undefined>()
|
||||
let permissionsDrawer = $state<DataTablePermissionsButton | undefined>()
|
||||
|
||||
async function runDatatableAction(datatable: string, action: DatatableRowAction) {
|
||||
actionDatatable = datatable
|
||||
// Let the keyed block above mount against the new target before driving it.
|
||||
await tick()
|
||||
switch (action) {
|
||||
case 'migrations':
|
||||
migrationsModal?.open()
|
||||
break
|
||||
case 'roles':
|
||||
permissionsDrawer?.open()
|
||||
break
|
||||
case 'export':
|
||||
await handleExportSchema(`datatable://${datatable}`)
|
||||
break
|
||||
case 'import':
|
||||
importTarget = `datatable://${datatable}`
|
||||
importDrawerOpen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function refreshManager() {
|
||||
dbManagerContent?.refresh()
|
||||
dbManagerContent?.dbManager()?.dbTable()?.refresh()
|
||||
refreshRoles()
|
||||
}
|
||||
|
||||
async function handleExportSchema() {
|
||||
const source = currentSourceIdentifier()
|
||||
/** Re-read what the tree and the role picker show: both are answers about the
|
||||
* data table's roles, which the permissions drawer can have just changed. */
|
||||
function refreshRoles() {
|
||||
datatables.refetch()
|
||||
usableRoles.refetch()
|
||||
}
|
||||
|
||||
async function handleExportSchema(explicitSource?: string) {
|
||||
const source = explicitSource ?? currentSourceIdentifier()
|
||||
if (!source || !ws) return
|
||||
try {
|
||||
exportResult = await WorkspaceService.exportPgSchema({
|
||||
@@ -137,7 +261,7 @@
|
||||
|
||||
async function handleImportDatabase() {
|
||||
if (!importSource || !ws) return
|
||||
const target = currentSourceIdentifier()
|
||||
const target = importTarget ?? currentSourceIdentifier()
|
||||
if (!target) return
|
||||
importLoading = true
|
||||
try {
|
||||
@@ -183,52 +307,48 @@
|
||||
noPadding
|
||||
id="db-manager-drawer"
|
||||
>
|
||||
{#if uriState.effectiveInput && ws}
|
||||
{#key uriState.selectedDatatable}
|
||||
{#if contentInput && ws && roleSettled}
|
||||
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
input={uriState.effectiveInput}
|
||||
input={contentInput}
|
||||
workspace={uriState.workspace}
|
||||
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
|
||||
datatableTreeLoading={datatables.loading}
|
||||
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
|
||||
onSelectRole={(dt, role) => {
|
||||
// Setting the data table clears the role, so the order matters.
|
||||
uriState.selectedDatatable = dt
|
||||
uriState.selectedRole = role
|
||||
}}
|
||||
bind:pendingAction
|
||||
canManageDatatable={!!($superadmin || $userStore?.is_admin) &&
|
||||
!!$enterpriseLicense &&
|
||||
!isCloudHosted()}
|
||||
onDatatableAction={runDatatableAction}
|
||||
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey={uriState.selectedSchema}
|
||||
bind:selectedTableKey={uriState.selectedTable}
|
||||
onImport={enableImportExport
|
||||
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
|
||||
? (mode) => (
|
||||
(importTarget = undefined),
|
||||
(importDrawerOpen = true),
|
||||
(importBehavior = mode)
|
||||
)
|
||||
: undefined}
|
||||
>
|
||||
{#snippet dbSelector()}
|
||||
{#if uriState.isDatatableInput}
|
||||
{#if datatables.loading}
|
||||
<div class="flex items-center gap-2 text-tertiary ml-2">
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
<span class="text-sm">Loading...</span>
|
||||
</div>
|
||||
{:else if datatables.current.length >= 1}
|
||||
<Select
|
||||
transformInputSelectedText={(s) => `Datatable: ${s}`}
|
||||
items={datatableItems}
|
||||
bind:value={uriState.selectedDatatable}
|
||||
placeholder="Select data table"
|
||||
size="md"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DBManagerContent>
|
||||
></DBManagerContent>
|
||||
{/key}
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
{#if uriState.isDatatableInput && uriState.selectedDatatable && ws}
|
||||
<DataTableMigrationsButton
|
||||
workspace={ws}
|
||||
datatable={uriState.selectedDatatable}
|
||||
onSchemaChanged={refreshManager}
|
||||
/>
|
||||
{/if}
|
||||
{#if enableImportExport}
|
||||
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
|
||||
<Button startIcon={{ icon: Upload }} onClick={() => (importDrawerOpen = true)}>
|
||||
<!-- A data table exports and imports from its row menu in the tree; a plain
|
||||
database has no tree row to hold them. -->
|
||||
{#if enableImportExport && !uriState.isDatatableInput}
|
||||
<Button startIcon={{ icon: Download }} onClick={() => handleExportSchema()}>Export</Button>
|
||||
<Button
|
||||
startIcon={{ icon: Upload }}
|
||||
onClick={() => ((importTarget = undefined), (importDrawerOpen = true))}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -260,6 +380,27 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#if actionDatatable && ws}
|
||||
{#key actionDatatable}
|
||||
<DataTableMigrationsButton
|
||||
bind:this={migrationsModal}
|
||||
hideTrigger
|
||||
workspace={ws}
|
||||
datatable={actionDatatable}
|
||||
onSchemaChanged={refreshManager}
|
||||
/>
|
||||
{#if $enterpriseLicense && !isCloudHosted()}
|
||||
<DataTablePermissionsButton
|
||||
bind:this={permissionsDrawer}
|
||||
hideTrigger
|
||||
workspace={ws}
|
||||
datatable={actionDatatable}
|
||||
onSaved={refreshRoles}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
|
||||
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
|
||||
{#if exportResult}
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
{/if}
|
||||
{#if askingForConfirmation?.codeContent}
|
||||
<div
|
||||
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group"
|
||||
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group min-w-0"
|
||||
>
|
||||
<button
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface-hover"
|
||||
@@ -512,9 +512,7 @@
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
</button>
|
||||
<pre class="whitespace-pre-wrap text-sm"
|
||||
><code>{askingForConfirmation.codeContent}</code></pre
|
||||
>
|
||||
<pre class="overflow-x-auto text-sm"><code>{askingForConfirmation.codeContent}</code></pre>
|
||||
</div>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import SelectDropdown from './select/SelectDropdown.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import { clickOutside } from '$lib/utils'
|
||||
|
||||
let {
|
||||
role,
|
||||
roles,
|
||||
onSelect
|
||||
}: {
|
||||
/** The role in effect, shown on the badge. */
|
||||
role: string
|
||||
/** The roles the caller may switch to. */
|
||||
roles: string[]
|
||||
onSelect: (role: string) => void
|
||||
} = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let anchorEl: HTMLSpanElement | undefined = $state()
|
||||
const items = $derived(roles.map((r) => ({ label: r, value: r })))
|
||||
|
||||
// The table picker's drawer opens at `disposables + 10000`, which the
|
||||
// dropdown's own z-index would sit under.
|
||||
const dropdownClass = 'z-[20000]'
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={anchorEl}
|
||||
class="relative flex min-w-0"
|
||||
use:clickOutside={{ onClickOutside: () => (open = false) }}
|
||||
>
|
||||
<Badge
|
||||
clickable
|
||||
color="gray"
|
||||
wrapperClass="min-w-0"
|
||||
class="min-w-0 gap-0.5 pl-2 pr-1 bg-surface-sunken hover:bg-surface-sunken text-primary
|
||||
transition-[filter,transform] hover:brightness-95 active:brightness-90 active:scale-[0.97]
|
||||
{open ? 'brightness-95' : ''}"
|
||||
onclick={(e) => {
|
||||
// The row underneath folds on click, and picking a role is not that.
|
||||
e.stopPropagation()
|
||||
open = !open
|
||||
}}
|
||||
>
|
||||
<!-- A long role name gives way rather than pushing the row's own actions
|
||||
past its right edge. -->
|
||||
<span class="truncate">{role}</span>
|
||||
<ChevronDown
|
||||
size={11}
|
||||
class="shrink-0 text-secondary transition-transform {open ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</Badge>
|
||||
<SelectDropdown
|
||||
processedItems={items}
|
||||
value={role}
|
||||
{open}
|
||||
listAutoWidth={false}
|
||||
class={dropdownClass}
|
||||
getInputRect={anchorEl && (() => anchorEl!.getBoundingClientRect())}
|
||||
onSelectValue={(item) => {
|
||||
open = false
|
||||
if (item.value !== role) onSelect(item.value)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
@@ -6,8 +6,18 @@
|
||||
import { joinSqlStatements, splitSqlRuns } from './sqlDdl'
|
||||
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
import { withMigrationRole } from './datatableMigrationRole'
|
||||
|
||||
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
role
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
/** The role the editor runs as. The migration declares it, or it would run as admin. */
|
||||
role?: string
|
||||
} = $props()
|
||||
|
||||
type Choice = 'run' | 'migrate' | 'cancel'
|
||||
|
||||
@@ -73,7 +83,7 @@
|
||||
function openMigrationModal(sql: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolveMigrationClosed = (created: boolean) => resolve(created)
|
||||
newMigrationModal?.open({ codeUp: sql })
|
||||
newMigrationModal?.open({ codeUp: withMigrationRole(sql, role) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +155,11 @@
|
||||
migrations rather than run ad-hoc. Create a migration for it instead?
|
||||
{/if}
|
||||
</p>
|
||||
{#if role}
|
||||
<p class="text-sm text-secondary">
|
||||
It will run as role <span class="font-mono">{role}</span>.
|
||||
</p>
|
||||
{/if}
|
||||
<pre
|
||||
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
|
||||
>{promptSql}</pre
|
||||
|
||||
@@ -1093,7 +1093,10 @@
|
||||
migrations are set up and used, how often an empty workspace home is seen, how often
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, the name of any public hub project imported from the home page and how far that
|
||||
import got, and whether a pre-approved trial offer was opened, last 30 days)</li
|
||||
import got, whether a pre-approved trial offer was opened, whether data tables are put
|
||||
under roles and whether callers name a role or take the default, and which kinds of
|
||||
access change (grant, revoke, ownership, default privileges) are applied to data
|
||||
tables, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
@@ -1159,7 +1162,10 @@
|
||||
migrations are set up and used, how often an empty workspace home is seen, how often
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, the name of any public hub project imported from the home page and how far that
|
||||
import got, and whether a pre-approved trial offer was opened, last 30 days)</li
|
||||
import got, whether a pre-approved trial offer was opened, whether data tables are put
|
||||
under roles and whether callers name a role or take the default, and which kinds of
|
||||
access change (grant, revoke, ownership, default privileges) are applied to data
|
||||
tables, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
|
||||
@@ -103,14 +103,21 @@
|
||||
// We don't always put the fix by default for row ordering concerns
|
||||
let transformedCode = code
|
||||
if (doPostgresRowToJsonFix) {
|
||||
transformedCode = statements
|
||||
.map((statement) => {
|
||||
if (READ_OPS.some((op) => statement.trim().toUpperCase().startsWith(op))) {
|
||||
return `SELECT row_to_json(__t__) FROM (${statement}) __t__`
|
||||
}
|
||||
return statement
|
||||
})
|
||||
.join(';')
|
||||
// Rebuilt from the pruned statements, which drops the leading comment block — and
|
||||
// with it the `-- role <name>` annotation that decides which login the query runs
|
||||
// as. Carry it over, or the retry connects as the data table's default role and a
|
||||
// query the first attempt was denied succeeds on the second.
|
||||
const leadingAnnotations = code.match(/^(?:[^\S\n]*\n|[^\S\n]*--[^\n]*\n)*/)?.[0] ?? ''
|
||||
transformedCode =
|
||||
leadingAnnotations +
|
||||
statements
|
||||
.map((statement) => {
|
||||
if (READ_OPS.some((op) => statement.trim().toUpperCase().startsWith(op))) {
|
||||
return `SELECT row_to_json(__t__) FROM (${statement}) __t__`
|
||||
}
|
||||
return statement
|
||||
})
|
||||
.join(';')
|
||||
}
|
||||
const dbArg = getDatabaseArg(input)
|
||||
|
||||
@@ -216,5 +223,10 @@
|
||||
</Splitpanes>
|
||||
|
||||
{#if datatableName && ws}
|
||||
<DdlMigrationGuard bind:this={ddlGuard} workspace={ws} datatable={datatableName} />
|
||||
<DdlMigrationGuard
|
||||
bind:this={ddlGuard}
|
||||
workspace={ws}
|
||||
datatable={datatableName}
|
||||
role={input.type === 'database' ? (input.role ?? input.migrationRole) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
kind: FavoriteKind
|
||||
summary?: string
|
||||
workspaceId?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
let { path, kind, workspaceId, summary }: Props = $props()
|
||||
let { path, kind, workspaceId, summary, size = 16 }: Props = $props()
|
||||
|
||||
let buttonHover = $state(false)
|
||||
let starred = $derived(favoriteManager.isStarred(path, kind))
|
||||
@@ -31,14 +32,14 @@
|
||||
>
|
||||
{#if starred}
|
||||
{#if buttonHover}
|
||||
<StarOff size={16} fill="currentcolor" />
|
||||
<StarOff {size} fill="currentcolor" />
|
||||
{:else}
|
||||
<Star size={16} fill="currentcolor" />
|
||||
<Star {size} fill="currentcolor" />
|
||||
{/if}
|
||||
{:else}
|
||||
<Star
|
||||
class={!buttonHover ? 'opacity-60' : ''}
|
||||
size={16}
|
||||
{size}
|
||||
fill={buttonHover ? 'currentcolor' : 'none'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -282,7 +282,8 @@ const scriptsV2: typeof legacyScripts = {
|
||||
...legacyScripts.postgresql,
|
||||
code: `
|
||||
SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns
|
||||
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')`
|
||||
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
|
||||
AND NOT starts_with(nsp.nspname, 'pg_') AND has_schema_privilege(nsp.oid, 'USAGE')`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
<Icon class={theme[type].classes.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
|
||||
<div class={twMerge('ml-0 text-left flex-1 min-w-0', showIcon ? 'ml-4' : '')}>
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
@@ -732,12 +732,14 @@ export class AIChatManager implements ChatViewHost {
|
||||
/** Every mounted flow editor. */
|
||||
#flowEditors = new Set<FlowAIChatHelpers>()
|
||||
appAiChatHelpers = $state<AppAIChatHelpers | undefined>(undefined)
|
||||
/** Datatable creation policy: enabled flag, datatable name, and optional schema */
|
||||
/** Datatable creation policy: enabled flag, datatable name, optional schema, and the role the
|
||||
* app uses each data table through */
|
||||
datatableCreationPolicy = $state<{
|
||||
enabled: boolean
|
||||
datatable: string | undefined
|
||||
schema: string | undefined
|
||||
}>({ enabled: false, datatable: undefined, schema: undefined })
|
||||
roles?: Record<string, string>
|
||||
}>({ enabled: false, datatable: undefined, schema: undefined, roles: undefined })
|
||||
pendingNewCode = $state<string | undefined>(undefined)
|
||||
apiTools = $state<Tool<any>[]>([])
|
||||
aiChatInput = $state<AIChatInput | null>(null)
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
<DefaultDatabaseSelector
|
||||
datatable={aiChatManager.datatableCreationPolicy.datatable}
|
||||
schema={aiChatManager.datatableCreationPolicy.schema}
|
||||
roles={aiChatManager.datatableCreationPolicy.roles}
|
||||
onChange={handleDefaultChange}
|
||||
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type AppCodeSelectionElement,
|
||||
type AppDatatableElement
|
||||
} from '../context'
|
||||
import { appDatatableRole, sdkDatatableCall } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
|
||||
// Backend runnable types
|
||||
export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline'
|
||||
@@ -921,9 +922,20 @@ export function prepareAppSystemMessage(customPrompt?: string): ChatCompletionSy
|
||||
const policy = aiChatManager.datatableCreationPolicy
|
||||
const datatableName = policy.datatable ?? 'main'
|
||||
const schemaPrefix = policy.schema ? `${policy.schema}.` : ''
|
||||
// Use wmill.datatable() for 'main' (default), otherwise wmill.datatable('name')
|
||||
const datatableCall =
|
||||
datatableName === 'main' ? 'wmill.datatable()' : `wmill.datatable('${datatableName}')`
|
||||
// A role names the privileges the app's queries run with, so it has to be in the code the
|
||||
// model writes.
|
||||
const datatableRole = appDatatableRole(policy.roles, datatableName)
|
||||
const tsDatatableCall = sdkDatatableCall(datatableName, datatableRole, 'typescript')
|
||||
const pyDatatableCall = sdkDatatableCall(datatableName, datatableRole, 'python')
|
||||
const roleEntries = Object.entries(policy.roles ?? {})
|
||||
const rolesNote =
|
||||
roleEntries.length > 0
|
||||
? `\n\nThis app uses these data tables through a role: ${roleEntries
|
||||
.map(([dt, role]) => `\`${dt}\` as \`${role}\``)
|
||||
.join(
|
||||
', '
|
||||
)}. Always pass that role when calling \`wmill.datatable\` on them, as in the examples. The role only reaches what it was granted, so a query on a table it lacks privileges on fails with \`permission denied\`.`
|
||||
: ''
|
||||
|
||||
let content = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend.
|
||||
|
||||
@@ -1024,7 +1036,7 @@ Backend runnables should only perform **data operations** (SELECT, INSERT, UPDAT
|
||||
import * as wmill from 'windmill-client';
|
||||
|
||||
export async function main(user_id: string) {
|
||||
const sql = ${datatableCall};
|
||||
const sql = ${tsDatatableCall};
|
||||
const user = await sql\`SELECT * FROM ${schemaPrefix}users WHERE id = \${user_id}\`.fetchOne();
|
||||
return user;
|
||||
}
|
||||
@@ -1035,12 +1047,12 @@ export async function main(user_id: string) {
|
||||
import wmill
|
||||
|
||||
def main(user_id: str):
|
||||
db = ${datatableCall}
|
||||
db = ${pyDatatableCall}
|
||||
user = db.query('SELECT * FROM ${schemaPrefix}users WHERE id = $1', user_id).fetch_one()
|
||||
return user
|
||||
\`\`\`
|
||||
|
||||
Use these examples for normal datatable access.
|
||||
Use these examples for normal datatable access.${rolesNote}
|
||||
|
||||
### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { WorkspaceService, type CompletedJob } from '$lib/gen'
|
||||
import type { DataTableTables } from '$lib/gen/types.gen'
|
||||
import { runScript } from '$lib/components/jobs/utils'
|
||||
import { datatableReference } from '$lib/components/dbTypes'
|
||||
import {
|
||||
createToolDef,
|
||||
executeTestRun,
|
||||
@@ -15,9 +16,9 @@ import {
|
||||
*
|
||||
* Datatables are workspace-level managed PostgreSQL databases. The backend
|
||||
* endpoints used here (`list_datatable_tables`, `get_datatable_table_schema`)
|
||||
* and SQL execution (`datatable://<name>`) are gated only by workspace
|
||||
* membership, so these tools need no app context and operate directly on the
|
||||
* workspace. This is the unrestricted counterpart to the app-mode datatable
|
||||
* and SQL execution (`datatable://<name>`) need no app context: the server
|
||||
* decides what the caller reaches, as the datatable role they name or its
|
||||
* default. This is the unrestricted counterpart to the app-mode datatable
|
||||
* tools in `app/core.ts`, which additionally filter by the app's whitelist.
|
||||
*/
|
||||
|
||||
@@ -31,9 +32,19 @@ const memo = <T>(factory: () => T): (() => T) => {
|
||||
|
||||
// ============= Pure workspace-scoped operations =============
|
||||
|
||||
/** List all datatables configured in the workspace, with their schema/table names. */
|
||||
export async function listDatatables(workspace: string): Promise<DataTableTables[]> {
|
||||
return await WorkspaceService.listDataTableTables({ workspace })
|
||||
/** List the datatables configured in the workspace, with their schema/table names: all of them as
|
||||
* their default role, or only `datatableName`, as `role` when one is given. */
|
||||
export async function listDatatables(
|
||||
workspace: string,
|
||||
datatableName?: string,
|
||||
role?: string
|
||||
): Promise<DataTableTables[]> {
|
||||
if (datatableName === undefined) return await WorkspaceService.listDataTableTables({ workspace })
|
||||
return await WorkspaceService.listDataTableTables({
|
||||
workspace,
|
||||
datatableName,
|
||||
...(role !== undefined && { roleFor: datatableName, role })
|
||||
})
|
||||
}
|
||||
|
||||
/** Get the columns (column_name -> compact_type) of one datatable table. */
|
||||
@@ -41,13 +52,15 @@ export async function getDatatableColumns(
|
||||
workspace: string,
|
||||
datatableName: string,
|
||||
schemaName: string,
|
||||
tableName: string
|
||||
tableName: string,
|
||||
role?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const schema = await WorkspaceService.getDataTableTableSchema({
|
||||
workspace,
|
||||
datatableName,
|
||||
schemaName,
|
||||
tableName
|
||||
tableName,
|
||||
role
|
||||
})
|
||||
return schema.columns
|
||||
}
|
||||
@@ -81,7 +94,26 @@ const NO_DATATABLES_CONFIGURED_MESSAGE =
|
||||
|
||||
// ============= Tool definitions =============
|
||||
|
||||
const getListDatatablesSchema = memo(() => z.object({}))
|
||||
// The same rule the server applies to `-- role <name>`; a name it would refuse fails here instead.
|
||||
const getRoleSchema = memo(() =>
|
||||
z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9_-]{1,63}$/)
|
||||
.optional()
|
||||
.describe(
|
||||
"The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role."
|
||||
)
|
||||
)
|
||||
|
||||
const getListDatatablesSchema = memo(() =>
|
||||
z.object({
|
||||
datatable_name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('List only this datatable. Required with `role`.'),
|
||||
role: getRoleSchema()
|
||||
})
|
||||
)
|
||||
const getListDatatablesToolDef = memo(() =>
|
||||
createToolDef(
|
||||
getListDatatablesSchema(),
|
||||
@@ -94,7 +126,8 @@ const getGetDatatableTableSchemaSchema = memo(() =>
|
||||
z.object({
|
||||
datatable_name: z.string().describe('The datatable name to inspect, e.g. "main".'),
|
||||
schema_name: z.string().describe('The schema name, e.g. "public".'),
|
||||
table_name: z.string().describe('The table name to inspect.')
|
||||
table_name: z.string().describe('The table name to inspect.'),
|
||||
role: getRoleSchema()
|
||||
})
|
||||
)
|
||||
const getGetDatatableTableSchemaToolDef = memo(() =>
|
||||
@@ -117,6 +150,7 @@ const getExecDatatableSqlSchema = memo(() =>
|
||||
.describe(
|
||||
'The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.'
|
||||
),
|
||||
role: getRoleSchema(),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -217,10 +251,18 @@ export function getDatatableTools(): Tool<{}>[] {
|
||||
{
|
||||
def: getListDatatablesToolDef(),
|
||||
planModeSafe: true,
|
||||
fn: async ({ workspace, toolId, toolCallbacks }) => {
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' })
|
||||
try {
|
||||
const metadata = await listDatatables(workspace)
|
||||
const parsedArgs = getListDatatablesSchema().parse(args ?? {})
|
||||
if (parsedArgs.role !== undefined && parsedArgs.datatable_name === undefined) {
|
||||
throw new Error('`role` needs `datatable_name`, the datatable it is a role of')
|
||||
}
|
||||
const metadata = await listDatatables(
|
||||
workspace,
|
||||
parsedArgs.datatable_name,
|
||||
parsedArgs.role
|
||||
)
|
||||
if (metadata.length === 0) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'No datatables configured — set one up in workspace settings'
|
||||
@@ -236,7 +278,18 @@ export function getDatatableTools(): Tool<{}>[] {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Listed ${metadata.length} datatable(s) with ${totalTables} table(s)`
|
||||
})
|
||||
return JSON.stringify(metadata, null, 2)
|
||||
// Only what the model acts on: the roles it may pass, not the creation privileges
|
||||
// the manager's UI gates on.
|
||||
return JSON.stringify(
|
||||
metadata.map((d) => ({
|
||||
datatable_name: d.datatable_name,
|
||||
schemas: d.schemas,
|
||||
...(d.error && { error: d.error }),
|
||||
...(d.permissioned && { usable_roles: d.usable_roles, default_role: d.default_role })
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
} catch (e) {
|
||||
const errorMsg = `Error listing datatables: ${e instanceof Error ? e.message : String(e)}`
|
||||
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
|
||||
@@ -257,7 +310,8 @@ export function getDatatableTools(): Tool<{}>[] {
|
||||
workspace,
|
||||
parsedArgs.datatable_name,
|
||||
parsedArgs.schema_name,
|
||||
parsedArgs.table_name
|
||||
parsedArgs.table_name,
|
||||
parsedArgs.role
|
||||
)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Retrieved schema for ${parsedArgs.schema_name}.${parsedArgs.table_name}`
|
||||
@@ -300,7 +354,7 @@ export function getDatatableTools(): Tool<{}>[] {
|
||||
requestBody: {
|
||||
language: 'postgresql',
|
||||
content: parsedArgs.sql,
|
||||
args: { database: `datatable://${name}` }
|
||||
args: { database: datatableReference(name, parsedArgs.role) }
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
|
||||
@@ -1406,7 +1406,12 @@ Data Tables:
|
||||
- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL.
|
||||
- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql.
|
||||
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.
|
||||
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.
|
||||
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.${
|
||||
isCloudHosted()
|
||||
? ''
|
||||
: `
|
||||
- A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.`
|
||||
}
|
||||
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.${
|
||||
skills.length > 0
|
||||
? `
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService, type AclChange, type AclTarget, type DatatableAclInfo } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { Trash2 } from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Alert, Button } from '../common'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import DataTable from '../table/DataTable.svelte'
|
||||
import Head from '../table/Head.svelte'
|
||||
import Row from '../table/Row.svelte'
|
||||
import Cell from '../table/Cell.svelte'
|
||||
import PgGrantBuilder from './PgGrantBuilder.svelte'
|
||||
import {
|
||||
ADMIN_ROLE,
|
||||
blockingSources,
|
||||
grantKey,
|
||||
grantScopeLabel,
|
||||
groupGrants,
|
||||
revocablePrivileges,
|
||||
revokeScopeOf,
|
||||
uncoveredCreators
|
||||
} from './aclScopes'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
target
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
/** What owner and grants are read and written for. */
|
||||
target: AclTarget
|
||||
} = $props()
|
||||
|
||||
const acl = resource(
|
||||
() => [workspace, datatable, target] as const,
|
||||
async ([ws, dt, t]) =>
|
||||
await WorkspaceService.getDatatableAcl({
|
||||
workspace: ws,
|
||||
datatableName: dt,
|
||||
kind: t.kind,
|
||||
schema: t.kind === 'database' ? undefined : t.schema,
|
||||
table: t.kind === 'table' ? t.table : undefined
|
||||
})
|
||||
)
|
||||
|
||||
// Nothing is written before its SQL has been shown, and the apply runs exactly that SQL: the
|
||||
// server plans again and refuses if the result differs.
|
||||
let pending = $state<
|
||||
{ change: AclChange; statements: string[]; warnings: string[]; title: string } | undefined
|
||||
>(undefined)
|
||||
let planning = $state(false)
|
||||
let applying = $state(false)
|
||||
|
||||
const info: DatatableAclInfo | undefined = $derived(acl.current)
|
||||
const grantRows = $derived(groupGrants(info?.grants ?? []))
|
||||
const ownerItems = $derived(
|
||||
info
|
||||
? (info.roles.includes(info.owner) ? info.roles : [info.owner, ...info.roles]).map((r) => ({
|
||||
value: r,
|
||||
label: r
|
||||
}))
|
||||
: []
|
||||
)
|
||||
/** A revoke listed per object takes them all: say that it does. */
|
||||
const pendingCoversObjects = $derived(
|
||||
pending?.change.type === 'revoke' && (pending.change.objects?.length ?? 0) > 1
|
||||
)
|
||||
|
||||
function errorText(e: any): string {
|
||||
return e?.body ?? e?.message ?? String(e)
|
||||
}
|
||||
|
||||
async function confirm(change: AclChange, title: string) {
|
||||
planning = true
|
||||
try {
|
||||
const plan = await WorkspaceService.planDatatableAcl({
|
||||
workspace,
|
||||
datatableName: datatable,
|
||||
requestBody: { target, change }
|
||||
})
|
||||
pending = { change, statements: plan.statements, warnings: plan.warnings, title }
|
||||
} catch (e) {
|
||||
sendUserToast(errorText(e), true)
|
||||
} finally {
|
||||
planning = false
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!pending) return
|
||||
applying = true
|
||||
try {
|
||||
await WorkspaceService.applyDatatableAcl({
|
||||
workspace,
|
||||
datatableName: datatable,
|
||||
requestBody: { target, change: pending.change, statements: pending.statements }
|
||||
})
|
||||
sendUserToast(pending.title)
|
||||
pending = undefined
|
||||
await acl.refetch()
|
||||
} catch (e) {
|
||||
sendUserToast(errorText(e), true)
|
||||
} finally {
|
||||
applying = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if acl.error}
|
||||
<Alert type="error" title="Could not read access" size="xs">{errorText(acl.error)}</Alert>
|
||||
{:else if !info}
|
||||
<span class="text-xs text-secondary">Loading…</span>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !info.editable}
|
||||
<span class="text-xs text-secondary">
|
||||
Read only: access is changed by the admins of the workspace that governs this data table, on
|
||||
Windmill Enterprise Edition.
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if target.kind !== 'database'}
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis">Owner</span>
|
||||
<span class="text-xs text-secondary">
|
||||
{target.kind === 'schema'
|
||||
? 'The role that owns the schema and everything already in it, except what belongs to an extension, which stays with the extension. Changing it also keeps the new owner in reach of what the current roles create here from then on; a role added afterwards is not covered.'
|
||||
: 'The role that owns the table. Its owner may always read and write it, and is who ALTER and DROP answer to.'}
|
||||
</span>
|
||||
</div>
|
||||
{#if info.editable}
|
||||
<Select
|
||||
items={ownerItems}
|
||||
disabled={planning || applying}
|
||||
size="sm"
|
||||
class="w-64"
|
||||
bind:value={
|
||||
() => info.owner,
|
||||
(role) => {
|
||||
// The select shows what the database says; a pick is a request, and only the
|
||||
// applied change moves it.
|
||||
if (role && role !== info.owner) {
|
||||
confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
{:else}
|
||||
<span class="font-mono text-xs">{info.owner}</span>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-emphasis">Grants</span>
|
||||
<span class="text-xs text-secondary">
|
||||
{target.kind === 'database'
|
||||
? 'What each role may do on the database itself — CREATE is the right to create schemas in it — and what default privileges set database-wide give it on what is created later, in every schema. No schema can take those back.'
|
||||
: 'What each role may do here, beyond what it owns.'}
|
||||
</span>
|
||||
</div>
|
||||
{#if info.editable}
|
||||
<PgGrantBuilder
|
||||
{target}
|
||||
roles={info.roles}
|
||||
disabled={planning || applying}
|
||||
supportsMaintain={info.supports_maintain}
|
||||
dbname={info.dbname}
|
||||
onAdd={({ role, privileges, scope }) =>
|
||||
confirm(
|
||||
{ type: 'grant', role, privileges, scope },
|
||||
`Granted ${privileges.join(', ')} to ${role}`
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{#if grantRows.length === 0}
|
||||
<span class="text-xs text-secondary">No grants yet.</span>
|
||||
{:else}
|
||||
<DataTable size="xs">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Role</Cell>
|
||||
<Cell head>Privileges</Cell>
|
||||
<Cell head>On</Cell>
|
||||
<Cell head last></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each grantRows as grant (grantKey(grant))}
|
||||
{@const revokeScope = revokeScopeOf(grant)}
|
||||
{@const revocable = revocablePrivileges(grant, target)}
|
||||
{@const blocked = blockingSources(grant, revocable)}
|
||||
{@const uncovered = uncoveredCreators(grant, info.roles)}
|
||||
<Row>
|
||||
<Cell first>{grant.grantee}</Cell>
|
||||
<Cell wrap
|
||||
><span class="font-mono text-2xs">{grant.privileges.join(', ')}</span></Cell
|
||||
>
|
||||
<Cell wrap>
|
||||
{grantScopeLabel(grant)}
|
||||
{#if blocked.length > 0}
|
||||
<span
|
||||
class="text-2xs text-secondary"
|
||||
title="Only this role can take the grant back: Postgres revokes a grant through the role that made it"
|
||||
>
|
||||
from {blocked.join(', ')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if uncovered.length > 0}
|
||||
<span
|
||||
class="text-2xs text-secondary"
|
||||
title="A default privilege covers only the roles it was granted for: grant it again to cover these"
|
||||
>
|
||||
· not for what {uncovered.join(', ')}
|
||||
{uncovered.length === 1 ? 'creates' : 'create'}
|
||||
</span>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell last>
|
||||
<!-- What `admin` holds is what every role here connects through, so it is not
|
||||
this editor's to take away. -->
|
||||
{#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Revoke {revocable.join(', ')}"
|
||||
disabled={planning || applying}
|
||||
onClick={() =>
|
||||
confirm(
|
||||
{
|
||||
type: 'revoke',
|
||||
role: grant.grantee,
|
||||
privileges: revocable,
|
||||
scope: revokeScope,
|
||||
objects: grant.objects
|
||||
},
|
||||
`Revoked ${revocable.join(', ')} from ${grant.grantee}`
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
open={!!pending}
|
||||
title="Run the following?"
|
||||
confirmationText="Run"
|
||||
type="info"
|
||||
alwaysPortal
|
||||
loading={applying}
|
||||
onConfirmed={apply}
|
||||
onCanceled={() => (pending = undefined)}
|
||||
>
|
||||
<div class="flex flex-col gap-3 min-w-0">
|
||||
{#if pendingCoversObjects}
|
||||
<Alert type="info" title="This covers every listed object" size="xs">
|
||||
The same privileges on several objects read as one row, and are revoked together.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#each pending?.warnings ?? [] as warning (warning)}
|
||||
<Alert type="warning" title="Warning" size="xs">{warning}</Alert>
|
||||
{/each}
|
||||
<span class="text-sm text-secondary">
|
||||
Runs against <span class="font-mono">{datatable}</span> in a single transaction:
|
||||
</span>
|
||||
<pre class="overflow-auto text-xs bg-surface-secondary p-3 rounded select-all max-h-80"
|
||||
>{(pending?.statements ?? []).join(';\n')};</pre
|
||||
>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import type { AclTarget } from '$lib/gen'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import Select from '../select/Select.svelte'
|
||||
import MultiSelect from '../select/MultiSelect.svelte'
|
||||
import { privilegesOf, scopeSql, scopesOf, type AclScope } from './aclScopes'
|
||||
|
||||
let {
|
||||
target,
|
||||
roles,
|
||||
supportsMaintain = false,
|
||||
dbname,
|
||||
disabled = false,
|
||||
onAdd
|
||||
}: {
|
||||
target: AclTarget
|
||||
/** Roles the grant can be handed to. */
|
||||
roles: string[]
|
||||
/** Postgres 17+, which has one more table privilege to offer. */
|
||||
supportsMaintain?: boolean
|
||||
/** Names the database in the statement a database target builds. */
|
||||
dbname?: string
|
||||
disabled?: boolean
|
||||
onAdd: (grant: { role: string; privileges: string[]; scope: AclScope }) => void
|
||||
} = $props()
|
||||
|
||||
let role = $state<string | undefined>(undefined)
|
||||
let scope = $state<AclScope>('target')
|
||||
let privileges = $state<string[]>([])
|
||||
|
||||
const available = $derived(privilegesOf(scope, target.kind, supportsMaintain))
|
||||
const statement = $derived(
|
||||
privileges.length && role
|
||||
? `GRANT ${privileges.join(', ')} ON ${scopeSql(scope, target, dbname)} TO ${role}`
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 border rounded-md p-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
||||
<span class="font-mono text-primary">GRANT</span>
|
||||
<MultiSelect
|
||||
bind:value={privileges}
|
||||
items={available.map((p) => ({ value: p, label: p }))}
|
||||
placeholder="privileges"
|
||||
{disabled}
|
||||
size="sm"
|
||||
class="min-w-48"
|
||||
/>
|
||||
<span class="font-mono text-primary">ON</span>
|
||||
<Select
|
||||
bind:value={
|
||||
() => scope,
|
||||
(s) => {
|
||||
if (!s) return
|
||||
scope = s
|
||||
// A privilege only exists for some objects — SELECT means nothing on a function —
|
||||
// so drop what the new scope cannot carry rather than send it.
|
||||
const allowed = privilegesOf(s, target.kind, supportsMaintain)
|
||||
privileges = privileges.filter((p) => allowed.includes(p))
|
||||
}
|
||||
}
|
||||
items={scopesOf(target.kind)}
|
||||
{disabled}
|
||||
size="sm"
|
||||
class="w-52"
|
||||
/>
|
||||
<span class="font-mono text-primary">TO</span>
|
||||
<Select
|
||||
bind:value={role}
|
||||
items={roles.map((r) => ({ value: r, label: r }))}
|
||||
placeholder="role"
|
||||
{disabled}
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={disabled || !role || privileges.length === 0}
|
||||
onClick={() => {
|
||||
if (!role) return
|
||||
onAdd({ role, privileges: [...privileges], scope })
|
||||
privileges = []
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{#if statement}
|
||||
<pre class="text-2xs text-tertiary overflow-x-auto">{statement}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AclGrant } from '$lib/gen'
|
||||
import {
|
||||
blockingSources,
|
||||
grantKey,
|
||||
groupGrants,
|
||||
revocablePrivileges,
|
||||
revokeScopeOf,
|
||||
uncoveredCreators
|
||||
} from './aclScopes'
|
||||
|
||||
const table = (name: string) => ({ name, kind: 'TABLE' })
|
||||
const by = (role: string, privileges: string[], reachable = true) => ({
|
||||
role,
|
||||
privileges,
|
||||
reachable
|
||||
})
|
||||
const byAdmin = (grant: Omit<AclGrant, 'sources'>): AclGrant => ({
|
||||
...grant,
|
||||
sources: [by('admin', grant.privileges)]
|
||||
})
|
||||
|
||||
describe('grantKey', () => {
|
||||
it('tells apart a table and a function of the same name', () => {
|
||||
const row = (object: { name: string; kind: string; args?: string }) => ({
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
objects: [object],
|
||||
sources: [by('admin', ['SELECT'])]
|
||||
})
|
||||
expect(grantKey(row(table('orders')))).not.toBe(
|
||||
grantKey(row({ name: 'orders', kind: 'FUNCTION', args: '' }))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupGrants', () => {
|
||||
// A row's revoke names every object in it, so a row must only hold what one revoke may take.
|
||||
it('folds the same privileges on objects of one kind, and nothing else', () => {
|
||||
const grants: AclGrant[] = [
|
||||
{ grantee: 'analytics', privileges: ['SELECT'], object: table('orders') },
|
||||
{ grantee: 'analytics', privileges: ['SELECT'], object: table('salaries') },
|
||||
{ grantee: 'operator', privileges: ['SELECT'], object: table('orders') },
|
||||
{ grantee: 'analytics', privileges: ['INSERT', 'SELECT'], object: table('events') },
|
||||
{ grantee: 'analytics', privileges: ['SELECT'], object: { name: 's', kind: 'SEQUENCE' } },
|
||||
{ grantee: 'analytics', privileges: ['SELECT'], future: 'TABLES' },
|
||||
{ grantee: 'analytics', privileges: ['USAGE'] }
|
||||
].map(byAdmin)
|
||||
const rows = groupGrants(grants)
|
||||
expect(rows.map((r) => [r.grantee, r.privileges, r.objects, r.future])).toEqual([
|
||||
['analytics', ['SELECT'], [table('orders'), table('salaries')], undefined],
|
||||
['operator', ['SELECT'], [table('orders')], undefined],
|
||||
['analytics', ['INSERT', 'SELECT'], [table('events')], undefined],
|
||||
['analytics', ['SELECT'], [{ name: 's', kind: 'SEQUENCE' }], undefined],
|
||||
['analytics', ['SELECT'], [], 'TABLES'],
|
||||
['analytics', ['USAGE'], [], undefined]
|
||||
])
|
||||
})
|
||||
|
||||
// A revoke takes the row back from every source, so the row must name them all, with what each
|
||||
// gave.
|
||||
it('keeps every source of the grants it folds', () => {
|
||||
const grants: AclGrant[] = [
|
||||
{
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
object: table('orders'),
|
||||
sources: [by('admin', ['SELECT'])]
|
||||
},
|
||||
{
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
object: table('salaries'),
|
||||
sources: [by('admin', ['SELECT']), by('operator', ['SELECT'])]
|
||||
}
|
||||
]
|
||||
expect(groupGrants(grants)[0].sources).toEqual([
|
||||
by('admin', ['SELECT']),
|
||||
by('operator', ['SELECT'])
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke of a row', () => {
|
||||
const row = (future?: string) => ({
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
objects: [],
|
||||
future,
|
||||
sources: [by('admin', ['SELECT'])]
|
||||
})
|
||||
|
||||
it('takes back only what the editor may revoke on the database', () => {
|
||||
const database = { ...row(), privileges: ['CONNECT', 'CREATE'] }
|
||||
expect(revocablePrivileges(database, { kind: 'database' })).toEqual(['CREATE'])
|
||||
expect(revocablePrivileges(database, { kind: 'schema', schema: 'public' })).toEqual([
|
||||
'CONNECT',
|
||||
'CREATE'
|
||||
])
|
||||
// Set database-wide, so not one a schema's revoke could take back either.
|
||||
const schemasLater = { ...row('SCHEMAS'), privileges: ['CREATE'] }
|
||||
expect(revocablePrivileges(schemasLater, { kind: 'database' })).toEqual([])
|
||||
})
|
||||
|
||||
it('maps default privileges to their scope, and refuses the ones it has none for', () => {
|
||||
expect(revokeScopeOf(row())).toBe('target')
|
||||
expect(revokeScopeOf(row('TABLES'))).toBe('future_tables')
|
||||
expect(revokeScopeOf(row('TYPES'))).toBeUndefined()
|
||||
expect(revokeScopeOf({ ...row(), objects: [{ name: 'mood', kind: 'TYPE' }] })).toBeUndefined()
|
||||
})
|
||||
|
||||
// Postgres takes a grant back only through its source: offering the revoke would promise what
|
||||
// the plan then refuses. But only the sources of what is revoked count: the catalog's CONNECT
|
||||
// on the database comes from its owner, out of reach, and must not hold back a CREATE the
|
||||
// editor granted.
|
||||
it('is held back only by a source out of reach for what it takes', () => {
|
||||
const database = {
|
||||
...row(),
|
||||
privileges: ['CONNECT', 'CREATE'],
|
||||
sources: [by('postgres', ['CONNECT'], false), by('admin', ['CREATE'])]
|
||||
}
|
||||
const revocable = revocablePrivileges(database, { kind: 'database' })
|
||||
expect(blockingSources(database, revocable)).toEqual([])
|
||||
expect(blockingSources(database, ['CONNECT'])).toEqual(['postgres'])
|
||||
const partly = {
|
||||
...row('TABLES'),
|
||||
sources: [by('admin', ['SELECT']), by('postgres', ['SELECT'], false)]
|
||||
}
|
||||
expect(blockingSources(partly, ['SELECT'])).toEqual(['postgres'])
|
||||
})
|
||||
|
||||
// Whether a grant can be taken back depends on its object, so a row folding several objects is
|
||||
// only revocable if each of its grants is.
|
||||
it('is held back by a source out of reach on any of the objects it folds', () => {
|
||||
const grants: AclGrant[] = [
|
||||
{
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
object: table('orders'),
|
||||
sources: [by('admin', ['SELECT'])]
|
||||
},
|
||||
{
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
object: table('salaries'),
|
||||
sources: [by('admin', ['SELECT'], false)]
|
||||
}
|
||||
]
|
||||
const [folded] = groupGrants(grants)
|
||||
expect(folded.objects).toHaveLength(2)
|
||||
expect(blockingSources(folded, ['SELECT'])).toEqual(['admin'])
|
||||
// Folding reads the grants, never rewrites them.
|
||||
expect(grants[0].sources[0].reachable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uncoveredCreators', () => {
|
||||
// A default privilege binds only the creating roles it was granted for: a role added since is
|
||||
// left out until the grant is made again.
|
||||
it('names the roles a created-later row leaves out', () => {
|
||||
const future = {
|
||||
grantee: 'analytics',
|
||||
privileges: ['SELECT'],
|
||||
objects: [],
|
||||
future: 'TABLES',
|
||||
sources: [by('admin', ['SELECT']), by('analytics', ['SELECT'])]
|
||||
}
|
||||
expect(uncoveredCreators(future, ['admin', 'analytics', 'late'])).toEqual(['late'])
|
||||
expect(uncoveredCreators({ ...future, future: undefined }, ['late'])).toEqual([])
|
||||
// Set by a role outside the catalog, it was never meant to cover the catalog's roles.
|
||||
expect(
|
||||
uncoveredCreators({ ...future, sources: [by('postgres', ['SELECT'], false)] }, [
|
||||
'admin',
|
||||
'late'
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { AclGrant, AclSource, AclTarget } from '$lib/gen'
|
||||
|
||||
/** The role a data table connects as without roles — `custom_instance_user` in Postgres. */
|
||||
export const ADMIN_ROLE = 'admin'
|
||||
|
||||
/** Privileges Postgres accepts per kind of object. Mirrors the whitelist the backend validates
|
||||
* against — a privilege missing here just cannot be built. */
|
||||
/** `CREATE` on a database is the right to create schemas in it, and the only database privilege
|
||||
* handed out here: `CONNECT` is managed with the instance's role catalog. */
|
||||
export const DATABASE_PRIVILEGES = ['CREATE']
|
||||
export const SCHEMA_PRIVILEGES = ['USAGE', 'CREATE']
|
||||
export const TABLE_PRIVILEGES = [
|
||||
'SELECT',
|
||||
'INSERT',
|
||||
'UPDATE',
|
||||
'DELETE',
|
||||
'TRUNCATE',
|
||||
'REFERENCES',
|
||||
'TRIGGER'
|
||||
]
|
||||
/** Postgres 17 and later only, so it is offered from what the server reports. */
|
||||
export const MAINTAIN_PRIVILEGE = 'MAINTAIN'
|
||||
export const SEQUENCE_PRIVILEGES = ['USAGE', 'SELECT', 'UPDATE']
|
||||
export const FUNCTION_PRIVILEGES = ['EXECUTE']
|
||||
|
||||
export type AclScope =
|
||||
| 'target'
|
||||
| 'all_tables'
|
||||
| 'all_sequences'
|
||||
| 'all_functions'
|
||||
| 'future_tables'
|
||||
| 'future_sequences'
|
||||
| 'future_functions'
|
||||
|
||||
export type AclTargetKind = AclTarget['kind']
|
||||
|
||||
/** The scopes a target can grant on, in the order the builder offers them. */
|
||||
export function scopesOf(kind: AclTargetKind): { value: AclScope; label: string }[] {
|
||||
if (kind === 'database') return [{ value: 'target', label: 'the database itself' }]
|
||||
if (kind === 'table') return [{ value: 'target', label: 'this table' }]
|
||||
return [
|
||||
{ value: 'target', label: 'the schema itself' },
|
||||
{ value: 'all_tables', label: 'all tables in it' },
|
||||
{ value: 'all_sequences', label: 'all sequences in it' },
|
||||
{ value: 'all_functions', label: 'all functions in it' },
|
||||
{ value: 'future_tables', label: 'tables created later' },
|
||||
{ value: 'future_sequences', label: 'sequences created later' },
|
||||
{ value: 'future_functions', label: 'functions created later' }
|
||||
]
|
||||
}
|
||||
|
||||
export function privilegesOf(
|
||||
scope: AclScope,
|
||||
kind: AclTargetKind,
|
||||
supportsMaintain = false
|
||||
): string[] {
|
||||
const tablePrivileges = supportsMaintain
|
||||
? [...TABLE_PRIVILEGES, MAINTAIN_PRIVILEGE]
|
||||
: TABLE_PRIVILEGES
|
||||
switch (scope) {
|
||||
case 'target':
|
||||
if (kind === 'database') return DATABASE_PRIVILEGES
|
||||
return kind === 'schema' ? SCHEMA_PRIVILEGES : tablePrivileges
|
||||
case 'all_tables':
|
||||
case 'future_tables':
|
||||
return tablePrivileges
|
||||
case 'all_sequences':
|
||||
case 'future_sequences':
|
||||
return SEQUENCE_PRIVILEGES
|
||||
case 'all_functions':
|
||||
case 'future_functions':
|
||||
return FUNCTION_PRIVILEGES
|
||||
}
|
||||
}
|
||||
|
||||
/** What a statement built at this scope reads as, for the builder's own preview. */
|
||||
export function scopeSql(scope: AclScope, target: AclTarget, dbname?: string): string {
|
||||
if (target.kind === 'database') return `DATABASE ${dbname ?? ''}`.trim()
|
||||
const schema = target.schema
|
||||
switch (scope) {
|
||||
case 'target':
|
||||
return target.kind === 'schema' ? `SCHEMA ${schema}` : `TABLE ${schema}.${target.table}`
|
||||
case 'all_tables':
|
||||
return `ALL TABLES IN SCHEMA ${schema}`
|
||||
case 'all_sequences':
|
||||
return `ALL SEQUENCES IN SCHEMA ${schema}`
|
||||
case 'all_functions':
|
||||
return `ALL FUNCTIONS IN SCHEMA ${schema}`
|
||||
case 'future_tables':
|
||||
return `TABLES (default privileges in ${schema})`
|
||||
case 'future_sequences':
|
||||
return `SEQUENCES (default privileges in ${schema})`
|
||||
case 'future_functions':
|
||||
return `FUNCTIONS (default privileges in ${schema})`
|
||||
}
|
||||
}
|
||||
|
||||
/** One row of the grants table: the same privileges on several objects read as one line, since
|
||||
* granting them per object is what `ON ALL TABLES` does. */
|
||||
export type GroupedGrant = {
|
||||
grantee: string
|
||||
privileges: string[]
|
||||
objects: NonNullable<AclGrant['object']>[]
|
||||
future?: string
|
||||
/** Every role the row's grants come from, each once, with what it gave. */
|
||||
sources: AclSource[]
|
||||
}
|
||||
|
||||
export function groupGrants(grants: AclGrant[]): GroupedGrant[] {
|
||||
const rows: GroupedGrant[] = []
|
||||
for (const grant of grants) {
|
||||
const existing = grant.object
|
||||
? rows.find(
|
||||
(r) =>
|
||||
r.grantee === grant.grantee &&
|
||||
r.future === grant.future &&
|
||||
r.objects[0]?.kind === grant.object?.kind &&
|
||||
r.privileges.join() === grant.privileges.join()
|
||||
)
|
||||
: undefined
|
||||
if (existing) {
|
||||
existing.objects.push(grant.object!)
|
||||
for (const source of grant.sources) {
|
||||
const known = existing.sources.find((s) => s.role === source.role)
|
||||
// Whether a role's grant can be taken back depends on the object it is on, so a row
|
||||
// holds a source as reachable only if it is on every object the row folds.
|
||||
if (known) {
|
||||
known.reachable &&= source.reachable
|
||||
known.privileges = [...new Set([...known.privileges, ...source.privileges])].sort()
|
||||
} else {
|
||||
existing.sources.push({ ...source, privileges: [...source.privileges] })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rows.push({
|
||||
grantee: grant.grantee,
|
||||
privileges: grant.privileges,
|
||||
objects: grant.object ? [grant.object] : [],
|
||||
future: grant.future,
|
||||
sources: grant.sources.map((s) => ({ ...s, privileges: [...s.privileges] }))
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The roles that gave some of `privileges` and that this data table's connection cannot act for.
|
||||
* Only they can take those grants back, so a revoke of `privileges` is not offered. */
|
||||
export function blockingSources(grant: GroupedGrant, privileges: string[]): string[] {
|
||||
return grant.sources
|
||||
.filter((s) => !s.reachable && s.privileges.some((p) => privileges.includes(p)))
|
||||
.map((s) => s.role)
|
||||
}
|
||||
|
||||
/** Which of `roles` a "created later" row granted for some of them does not cover. A default
|
||||
* privilege binds only the creating roles it was granted for, so what the others create stays out
|
||||
* of it. A row none of `roles` set — the instance's own, say — was never meant to cover them, and
|
||||
* names none. */
|
||||
export function uncoveredCreators(grant: GroupedGrant, roles: string[]): string[] {
|
||||
if (!grant.future || !grant.sources.some((s) => roles.includes(s.role))) return []
|
||||
return roles.filter((r) => !grant.sources.some((s) => s.role === r))
|
||||
}
|
||||
|
||||
/** A row's identity. Two rows may share a grantee and an object name — a table `orders` and a
|
||||
* function `orders()` — so the kind and the privileges are part of it too. */
|
||||
export function grantKey(grant: GroupedGrant): string {
|
||||
return [
|
||||
grant.grantee,
|
||||
grant.future ?? '',
|
||||
grant.privileges.join(','),
|
||||
...grant.objects.map((o) => `${o.kind}:${o.name}(${o.args ?? ''})`)
|
||||
].join('|')
|
||||
}
|
||||
|
||||
/** The scope a revoke of this row takes, or `undefined` when the builder cannot express it —
|
||||
* Postgres also records privileges on types, present and default, which nothing here grants and
|
||||
* the API has no scope for. */
|
||||
export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined {
|
||||
if (!grant.future) return grant.objects.some((o) => o.kind === 'TYPE') ? undefined : 'target'
|
||||
const scope = `future_${grant.future.toLowerCase()}`
|
||||
return (['future_tables', 'future_sequences', 'future_functions'] as const).find(
|
||||
(s) => s === scope
|
||||
)
|
||||
}
|
||||
|
||||
/** The privileges of a row a revoke may take back. On the database that is `CREATE` alone:
|
||||
* `CONNECT` belongs to the role catalog, which would grant it again, and `TEMPORARY` is not one
|
||||
* the editor hands out — a row holding only those has nothing to revoke here. A database's rows
|
||||
* "created later" are default privileges set database-wide, which nothing here revokes. */
|
||||
export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): string[] {
|
||||
if (target.kind === 'database' && grant.objects.length === 0) {
|
||||
if (grant.future) return []
|
||||
return grant.privileges.filter((p) => DATABASE_PRIVILEGES.includes(p))
|
||||
}
|
||||
return grant.privileges
|
||||
}
|
||||
|
||||
/** How a row reads back: what it covers, in one phrase. */
|
||||
export function grantScopeLabel(grant: GroupedGrant): string {
|
||||
if (grant.future) return `${grant.future.toLowerCase()} created later`
|
||||
if (grant.objects.length === 1) {
|
||||
const object = grant.objects[0]
|
||||
// A routine's arguments are part of what it is, so two of the same name would otherwise
|
||||
// read as one row twice.
|
||||
const args = object.args !== undefined ? `(${object.args})` : ''
|
||||
return `${object.kind.toLowerCase()} ${object.name}${args}`
|
||||
}
|
||||
if (grant.objects.length > 1)
|
||||
return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s`
|
||||
return 'itself'
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { parseMigrationRole, withMigrationRole } from './datatableMigrationRole'
|
||||
|
||||
describe('parseMigrationRole', () => {
|
||||
test('reads every spelling the server accepts from the leading comment block', () => {
|
||||
for (const line of [
|
||||
'-- role analyst',
|
||||
'-- Role: analyst',
|
||||
'-- role=analyst',
|
||||
'-- role analyst;'
|
||||
]) {
|
||||
expect(parseMigrationRole(`\n${line}\nBEGIN;\nEND;`)).toEqual({
|
||||
kind: 'role',
|
||||
role: 'analyst'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('an annotation below BEGIN is not one', () => {
|
||||
expect(parseMigrationRole('BEGIN;\n-- role analyst\nEND;')).toEqual({ kind: 'none' })
|
||||
})
|
||||
|
||||
test('a malformed attempt is an error, not the default', () => {
|
||||
for (const line of [
|
||||
'-- role based access below',
|
||||
'-- role',
|
||||
'-- role:',
|
||||
'-- role an;alytics'
|
||||
]) {
|
||||
expect(parseMigrationRole(`${line}\nBEGIN;`)).toEqual({ kind: 'malformed', line })
|
||||
}
|
||||
})
|
||||
|
||||
test('comments that do not start with the word role are ignored', () => {
|
||||
expect(parseMigrationRole('-- roles analyst\n-- rolex\nBEGIN;')).toEqual({ kind: 'none' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('withMigrationRole', () => {
|
||||
test('leads above BEGIN, so the server reads it', () => {
|
||||
const out = withMigrationRole('BEGIN;\nSELECT 1;\nEND;', 'analyst')
|
||||
expect(out).toBe('-- role analyst\nBEGIN;\nSELECT 1;\nEND;')
|
||||
})
|
||||
|
||||
test('replaces any attempt rather than stacking, malformed ones included', () => {
|
||||
const out = withMigrationRole(
|
||||
'-- Role: auditor\n-- role oops no\n-- keep me\nBEGIN;',
|
||||
'analyst'
|
||||
)
|
||||
expect(out).toBe('-- role analyst\n-- keep me\nBEGIN;')
|
||||
})
|
||||
|
||||
test('undefined strips the annotation, so it runs as admin', () => {
|
||||
expect(withMigrationRole('-- role analyst\n\nBEGIN;\nEND;', undefined)).toBe('BEGIN;\nEND;')
|
||||
})
|
||||
|
||||
test('refuses a name the server would refuse', () => {
|
||||
expect(() => withMigrationRole('BEGIN;', 'bad;name')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { isDatatableRoleName } from './dbTypes'
|
||||
|
||||
/**
|
||||
* A migration carries the data table role it runs as in its own SQL, as a `-- role <name>`
|
||||
* annotation. There is no separate field: the annotation is what the server reads, and keeping
|
||||
* it in the SQL is what lets it survive a `wmill sync` round-trip.
|
||||
*
|
||||
* Mirrors `SqlAnnotations::datatable_role` on the backend. It is only read from the leading
|
||||
* comment block, so an annotation below `BEGIN;` is ignored and the migration runs as admin. A
|
||||
* leading comment whose first word is `role` is an annotation attempt, and a malformed one is an
|
||||
* error there, so it is one here too.
|
||||
*/
|
||||
|
||||
export type MigrationRole =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'role'; role: string }
|
||||
| { kind: 'malformed'; line: string }
|
||||
|
||||
/** The body of a leading comment line that attempts a role annotation, or undefined. */
|
||||
function roleAttempt(line: string): string | undefined {
|
||||
if (!line.startsWith('--')) return undefined
|
||||
const body = line.slice(2).trimStart()
|
||||
if (body.slice(0, 4).toLowerCase() !== 'role') return undefined
|
||||
const after = body.slice(4)
|
||||
if (after !== '' && !/^[\s:=]/.test(after)) return undefined
|
||||
return after
|
||||
}
|
||||
|
||||
function parseAttempt(after: string): string | undefined {
|
||||
let rest = after.trimStart()
|
||||
if (rest.startsWith(':') || rest.startsWith('=')) rest = rest.slice(1)
|
||||
const tokens = rest.split(/\s+/).filter((t) => t !== '')
|
||||
if (tokens.length !== 1) return undefined
|
||||
const role = tokens[0].endsWith(';') ? tokens[0].slice(0, -1) : tokens[0]
|
||||
return isDatatableRoleName(role) ? role : undefined
|
||||
}
|
||||
|
||||
export function parseMigrationRole(sql: string): MigrationRole {
|
||||
for (const raw of sql.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (line === '') continue
|
||||
if (!line.startsWith('--')) break
|
||||
const after = roleAttempt(line)
|
||||
if (after === undefined) continue
|
||||
const role = parseAttempt(after)
|
||||
return role === undefined ? { kind: 'malformed', line } : { kind: 'role', role }
|
||||
}
|
||||
return { kind: 'none' }
|
||||
}
|
||||
|
||||
/**
|
||||
* `sql` declaring `role`: any role annotation attempt in the leading comment block is removed,
|
||||
* and `-- role <role>` is prepended above everything, or nothing when `role` is undefined.
|
||||
*/
|
||||
export function withMigrationRole(sql: string, role: string | undefined): string {
|
||||
if (role !== undefined && !isDatatableRoleName(role)) {
|
||||
throw new Error(`Invalid data table role '${role}'`)
|
||||
}
|
||||
const lines = sql.split('\n')
|
||||
const kept: string[] = []
|
||||
let i = 0
|
||||
for (; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (line !== '' && !line.startsWith('--')) break
|
||||
if (roleAttempt(line) === undefined) kept.push(lines[i])
|
||||
}
|
||||
const rest = [...kept, ...lines.slice(i)]
|
||||
while (rest.length > 0 && rest[0].trim() === '') rest.shift()
|
||||
return role === undefined ? rest.join('\n') : [`-- role ${role}`, ...rest].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { OpenAPI, type ListUsableDatatableRolesResponse } from '$lib/gen'
|
||||
import { request } from '$lib/gen/core/request'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { ADMIN_DATATABLE_ROLE } from './dbTypes'
|
||||
|
||||
// `datatable_roles_unavailable` on the server, which is a plain 400: rewording it there without
|
||||
// here makes every role picker on a non-Enterprise build fail instead of reading "not under roles".
|
||||
const ROLES_UNAVAILABLE = 'Data table roles are a Windmill Enterprise Edition feature'
|
||||
|
||||
const NOT_UNDER_ROLES: ListUsableDatatableRolesResponse = {
|
||||
permissioned: false,
|
||||
roles: [],
|
||||
default_role: ADMIN_DATATABLE_ROLE
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles the caller may connect as on a data table. Cloud has no instance database, so no data
|
||||
* table there is under roles, and none of the role pickers show. Without the Enterprise Edition
|
||||
* every roles route refuses, which reads the same way: the data table is then used the way it was
|
||||
* before roles, and one that is under roles is refused when something connects to it.
|
||||
*/
|
||||
export async function listUsableDatatableRoles(
|
||||
workspace: string,
|
||||
datatableName: string
|
||||
): Promise<ListUsableDatatableRolesResponse> {
|
||||
if (isCloudHosted()) return NOT_UNDER_ROLES
|
||||
try {
|
||||
// The generated client encodes path params with `encodeURI`, which leaves a '?' in a
|
||||
// data table name created before names were restricted to cut the path short.
|
||||
return await request(
|
||||
{ ...OpenAPI, ENCODE_PATH: encodeURIComponent },
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}',
|
||||
path: { workspace, datatable_name: datatableName }
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
const body = (e as { body?: unknown })?.body
|
||||
const detail = `${typeof body === 'string' ? body : JSON.stringify(body ?? '')} ${(e as Error)?.message ?? e}`
|
||||
if (detail.includes(ROLES_UNAVAILABLE)) return NOT_UNDER_ROLES
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { isDbType } from './dbTypes'
|
||||
|
||||
/**
|
||||
* Single URL param `dbm` encodes the full DB manager state:
|
||||
* firstSegment~path~schema.table
|
||||
* firstSegment~path~schema.table~role=name
|
||||
*
|
||||
* firstSegment:
|
||||
* datatable – database with datatable:// resource (resourceType always postgresql)
|
||||
@@ -26,28 +26,46 @@ import { isDbType } from './dbTypes'
|
||||
* datatable~main~.customers (schema "public" implied)
|
||||
* ducklake~main~.orders (schema "main" implied)
|
||||
* postgresql~$res:u/user/my_pg~public.customers
|
||||
* datatable~main~.customers~role=analyst
|
||||
* datatable~main~role=analyst (no schema/table selected)
|
||||
*
|
||||
* role=name (last segment, optional, data tables only): the data table role to connect as.
|
||||
* Omitted means the data table's default role. A trailing segment starting with `role=` is always
|
||||
* the role, whatever follows. The name is kept as written, even when invalid (a `.` included), so
|
||||
* the connection refuses it visibly instead of falling back to the default.
|
||||
*/
|
||||
|
||||
const dbManagerSchema = z.object({
|
||||
dbm: z.string().nullable()
|
||||
})
|
||||
|
||||
interface ParsedDbm {
|
||||
export interface ParsedDbm {
|
||||
type: 'database' | 'datatable' | 'ducklake'
|
||||
path: string
|
||||
resType?: string
|
||||
schema?: string
|
||||
table?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
const ROLE_SEGMENT_PREFIX = 'role='
|
||||
|
||||
function isRoleSegment(segment: string | undefined): segment is string {
|
||||
return !!segment && segment.startsWith(ROLE_SEGMENT_PREFIX)
|
||||
}
|
||||
|
||||
export function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
const parts = raw.split('~')
|
||||
if (parts.length < 2 || !parts[1]) return null
|
||||
|
||||
const firstSeg = parts[0]
|
||||
const path = parts[1]
|
||||
const schemaTable = parts[2] ?? ''
|
||||
const rest = parts.slice(2)
|
||||
const role = isRoleSegment(rest.at(-1))
|
||||
? rest.pop()!.slice(ROLE_SEGMENT_PREFIX.length)
|
||||
: undefined
|
||||
const schemaTable = rest[0] ?? ''
|
||||
|
||||
let type: ParsedDbm['type']
|
||||
let resType: string | undefined
|
||||
@@ -81,12 +99,12 @@ function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
schema = defaultSchemas[type]
|
||||
}
|
||||
|
||||
return { type, path, resType, schema, table }
|
||||
return { type, path, resType, schema, table, role: type === 'datatable' ? role : undefined }
|
||||
}
|
||||
|
||||
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
|
||||
|
||||
function buildDbm(p: ParsedDbm): string {
|
||||
export function buildDbm(p: ParsedDbm): string {
|
||||
const firstSeg = p.type === 'database' ? p.resType! : p.type
|
||||
const schema = p.schema === defaultSchemas[p.type] ? undefined : p.schema
|
||||
let schemaTable = ''
|
||||
@@ -97,7 +115,12 @@ function buildDbm(p: ParsedDbm): string {
|
||||
} else if (schema) {
|
||||
schemaTable = `${schema}.`
|
||||
}
|
||||
return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}`
|
||||
const segments = [firstSeg, p.path]
|
||||
if (schemaTable) segments.push(schemaTable)
|
||||
if (p.type === 'datatable' && p.role !== undefined) {
|
||||
segments.push(`${ROLE_SEGMENT_PREFIX}${p.role}`)
|
||||
}
|
||||
return segments.join('~')
|
||||
}
|
||||
|
||||
export interface DbManagerUriState {
|
||||
@@ -105,6 +128,8 @@ export interface DbManagerUriState {
|
||||
readonly effectiveInput: DbInput | undefined
|
||||
readonly isDatatableInput: boolean
|
||||
selectedDatatable: string | undefined
|
||||
/** The data table role the drawer connects as; undefined means its default. */
|
||||
selectedRole: string | undefined
|
||||
selectedSchema: string | undefined
|
||||
selectedTable: string | undefined
|
||||
readonly open: boolean
|
||||
@@ -137,6 +162,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
type: 'database' as const,
|
||||
resourceType: resType as DbType,
|
||||
resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path,
|
||||
role: parsed.role,
|
||||
specificSchema: parsed.schema,
|
||||
specificTable: parsed.table
|
||||
}
|
||||
@@ -163,6 +189,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
type: isDatatable ? 'datatable' : 'database',
|
||||
path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath,
|
||||
resType: isDatatable ? undefined : nInput.resourceType,
|
||||
role: isDatatable ? nInput.role : undefined,
|
||||
schema: nInput.specificSchema,
|
||||
table: nInput.specificTable
|
||||
})
|
||||
@@ -194,7 +221,14 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
return parsed?.type === 'datatable' ? parsed.path : undefined
|
||||
},
|
||||
set selectedDatatable(v: string | undefined) {
|
||||
if (v) updateField({ path: v })
|
||||
// A role belongs to one data table, so it cannot carry over to another.
|
||||
if (v) updateField({ path: v, role: undefined })
|
||||
},
|
||||
get selectedRole() {
|
||||
return parsed?.role
|
||||
},
|
||||
set selectedRole(v: string | undefined) {
|
||||
updateField({ role: v })
|
||||
},
|
||||
get selectedSchema() {
|
||||
return parsed?.schema
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user