mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-18 00:02:27 +00:00
Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a303d4e6 | ||
|
|
6680c1454b | ||
|
|
47adfc0844 | ||
|
|
2457db4da4 | ||
|
|
a431bf27e4 | ||
|
|
f897805978 | ||
|
|
e1f387914e | ||
|
|
f9464c2b8e | ||
|
|
8842dea7f7 | ||
|
|
e6c91aebeb | ||
|
|
c08cbc98f7 | ||
|
|
e913381888 | ||
|
|
0c54cdb752 | ||
|
|
01567c2d30 | ||
|
|
73628dea4b | ||
|
|
1aac111b80 | ||
|
|
ddae0ec609 | ||
|
|
d4c0c83e0b | ||
|
|
af0477c16f | ||
|
|
6eb63fa58b | ||
|
|
63ef7663fc | ||
|
|
c6c26fbd92 | ||
|
|
1e6d28def2 | ||
|
|
95c3059266 | ||
|
|
ec19b0fe21 | ||
|
|
443d8a5316 | ||
|
|
3de44a8f8d | ||
|
|
9580e9985a | ||
|
|
1b82a7c236 | ||
|
|
7cd6e081a7 | ||
|
|
8a40ff46fd | ||
|
|
b1474820b5 | ||
|
|
93590da9d8 | ||
|
|
7c68818da1 | ||
|
|
a253ba3d11 | ||
|
|
6608180e3e | ||
|
|
bdcd42ac0a | ||
|
|
4638f35951 | ||
|
|
1e2052d74a | ||
|
|
3d2fa49358 | ||
|
|
2d8a99d2a3 | ||
|
|
8868c5ff72 | ||
|
|
1e3e08566d | ||
|
|
7f09781926 | ||
|
|
e6ca0de9c3 | ||
|
|
a274fd85bc | ||
|
|
e4ce3a466a | ||
|
|
2d09d33997 | ||
|
|
8bb2b0d777 | ||
|
|
b58a036dd8 | ||
|
|
43d2c29988 | ||
|
|
42271dcf7f | ||
|
|
f3e3281336 | ||
|
|
1f872c5edb | ||
|
|
23c1fc470f | ||
|
|
dfc06bf821 | ||
|
|
2c55c3918c | ||
|
|
625753c2d4 | ||
|
|
0fc9ba39a7 | ||
|
|
83acf4ba56 | ||
|
|
65401f39fe | ||
|
|
2cde025d8e | ||
|
|
c289a40e5b | ||
|
|
68e7f72560 | ||
|
|
98adfd247b | ||
|
|
88d6e11ff0 | ||
|
|
6e79d9636f | ||
|
|
be186e6fdd | ||
|
|
d6f36d3135 | ||
|
|
9a63c6884d | ||
|
|
6768e2a9e9 | ||
|
|
8a949471d0 | ||
|
|
fa4c06658f | ||
|
|
45d5eeda0e | ||
|
|
28b31a437e | ||
|
|
5f59b199b1 | ||
|
|
159a550df8 | ||
|
|
2bc39481da | ||
|
|
7619a7a51e | ||
|
|
1f6aec5470 | ||
|
|
b2b6ee773b | ||
|
|
70c610cc78 | ||
|
|
9e6c5cb524 | ||
|
|
a4e6eb5e4d | ||
|
|
e3f0a22810 | ||
|
|
67a4e2c5ef | ||
|
|
22f99f99f1 | ||
|
|
09cab14813 | ||
|
|
fea9f1836c | ||
|
|
709cf8aecb | ||
|
|
eb95a95f48 | ||
|
|
a8a8812839 | ||
|
|
7b94a0fb4d | ||
|
|
e989a4d9ae | ||
|
|
6e76edb279 | ||
|
|
19035031b7 | ||
|
|
baaf7632a3 | ||
|
|
fc3732fb7f | ||
|
|
38023de582 | ||
|
|
422cc98130 | ||
|
|
e1556004f6 | ||
|
|
9ebca89e28 | ||
|
|
1eb314e8cb | ||
|
|
395eac68c7 | ||
|
|
ce89023cef | ||
|
|
924389a0ac | ||
|
|
1244d8015d | ||
|
|
8429d909d4 | ||
|
|
bac89b0790 | ||
|
|
7effde8bbe | ||
|
|
163ff3aad6 | ||
|
|
126204a11e | ||
|
|
6774ddc81b | ||
|
|
803e89319e | ||
|
|
c4e350e85c | ||
|
|
7e00022f24 | ||
|
|
7570349f2e | ||
|
|
bebd8bef8b | ||
|
|
ae162b6d3e | ||
|
|
7e5ac78447 | ||
|
|
983b514a5d | ||
|
|
ba69fee329 | ||
|
|
77965f1458 | ||
|
|
48294239e5 | ||
|
|
3f92ab39ad | ||
|
|
18d6302337 | ||
|
|
44314d6e7a | ||
|
|
b15bac1916 | ||
|
|
bdb59ea399 | ||
|
|
e94d1b63ed | ||
|
|
871f2054a5 | ||
|
|
0cc25e2904 | ||
|
|
61f5e1b1f3 | ||
|
|
b3bee6ed1c | ||
|
|
2eda17d6a2 | ||
|
|
92eec3f696 | ||
|
|
4a20ac3ffd | ||
|
|
665cadbca6 | ||
|
|
6228e266a1 | ||
|
|
326fb5e5cb | ||
|
|
004b73ff37 | ||
|
|
4e53d1ac45 | ||
|
|
5974f5491d | ||
|
|
98cf770d73 | ||
|
|
77ebdf08b2 | ||
|
|
62ac83431d | ||
|
|
4717fff51f | ||
|
|
dbf8af2116 | ||
|
|
58df573b15 | ||
|
|
399438bca1 | ||
|
|
afdf83d2cb | ||
|
|
7285e50b76 | ||
|
|
23cd7737b6 |
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path, postgres_resource_path\n FROM postgres_trigger WHERE postgres_resource_path LIKE 'datatable://%'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "postgres_resource_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1100d1ab19ed3bf73ce29af00d7a06605e7b697324813a1dce1c2fd4c72bc713"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)\n WHERE workspace_id = $1\n AND datatable->'datatables' ? $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "161cc8750a9c6f22b28bc0cc565c621cd3e6ee378587620deb0ea2f69f1fcbdb"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM usr WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1ad8677694aca94ee0e6da287d7cc028dcf673583a0e3e4fedd0e5d6766c5860"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = datatable #- ARRAY['datatables', $2, 'permissions']\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2daf3786f0b7195a3acfd80e01f2dba171ae5850f22503e846b7355a4f250611"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id,\n jsonb_each(ws.datatable->'datatables') dt\n WHERE ws.workspace_id = ANY($1)\n AND dt.value->'database' = $2\n AND dt.value->'forked_from' IS NULL\n ORDER BY ws.workspace_id, dt.key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deleted!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "34a6b159360d652fac09662cedab4a490be9db7e331874cbfa7b1b93e50af586"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE database_key = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "database_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "owner_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "permissions",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3abcd060b4e1865f17e73ae7e17b0ba654f21458fb4d2d0a55e5c3eab29bb279"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext('datatable_database_permissions:' || $1))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3cb0fdbb787328c61696a3f4657041bf4019929e5911cf4f144f24dd6916b106"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE datatable_database_permissions SET owner_workspace_id = $1 WHERE owner_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3f11db5b3f8af739cb287f45071f8e315e3e078b3d130d0db48277306b189ea9"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "43ce5ba6277a6e7284b2d5e9fe29f5489d8ceccb7c971226108ed600278cfbea"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', (\n SELECT COALESCE(jsonb_object_agg(\n key,\n CASE WHEN key = ANY($2) THEN value ELSE value - 'forked_from' END\n ), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "47fc4d3ffd3ace0beb29cd3c4c3125c1d0a76781c8340e65a72804f469d0f6c8"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)\n WHERE workspace_id = $1 AND datatable->'datatables' ? $2\n RETURNING workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "49ce3f131435f763dad8312c1efc569f005682897d2d8525702ea77c1f9ea99d"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name AS \"name!\" FROM folder WHERE workspace_id = $1 AND name = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4a28a373afac48cce81f4607e437be329096b574108bc0ec41290d993318991d"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr SET username = $1 WHERE email = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "58002bfd04efea3d5f955d0bc5073a916b00fd73600943b98f69639fac2071be"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT username AS \"username!\" FROM usr WHERE workspace_id = $1 AND username = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5d8a2a1e4ee7f63049f910ed56a71a8f2db475177732931d7973f2053ad79442"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id AS \"workspace_id!\", datatable AS \"datatable!\", name AS \"name!\",\n code_up AS \"code_up!\", code_down\n FROM datatable_migrations\n WHERE code_up LIKE '%--%role%' OR code_down LIKE '%--%role%'\n ORDER BY workspace_id, datatable, timestamp",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "code_up!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "code_down",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5e63f9b708d669234c600ffcef19f2cd218e8ea441193fb55b6ccf916cf92477"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "63c16a4277983aaed0aed54972923919cee3cc444725ac6b7906922554bae800"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64d40ff3de929d96a3d03c7d5b363f1284faf50fa3a0c5f4d0beab560d14c7e6"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE owner_workspace_id = $1\n ORDER BY database_key FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "database_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "owner_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "permissions",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "82f7f52f60868a3aa659f2bee6b41f357a09e2c94792542287cbbc810451c47a"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO datatable_database_permissions (database_key, owner_workspace_id, permissions)\n VALUES ($1, $2, $3)\n ON CONFLICT (database_key) DO UPDATE\n SET permissions = EXCLUDED.permissions,\n owner_workspace_id = COALESCE(datatable_database_permissions.owner_workspace_id, EXCLUDED.owner_workspace_id),\n updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "842a5c2694c996897f17e763498bbefaef59a77846cf63e376ba544f6dd9cf65"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE owner_workspace_id = $1 ORDER BY database_key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "database_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "owner_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "permissions",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "852c7619569dd676b24319267c3ba1ea98aae07a9c2e30de79b3db411e1ef3e9"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id,\n jsonb_each(ws.datatable->'datatables') dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database' = $2\n ORDER BY ws.workspace_id, dt.key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deleted!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8e1be90e8f9f7a4dd067cd9d662ee67ceb2082c94bb2b44f020654a08c6042fc"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name AS \"name!\", code_up AS \"code_up!\", code_down\n FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2\n ORDER BY timestamp",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "code_up!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "code_down",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "91e917b304b39d64782fa632b912e8760a6ed43227326238e0ff20e372bdde17"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT database_key, owner_workspace_id, permissions\n FROM datatable_database_permissions WHERE database_key = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "database_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "owner_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "permissions",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "95671af1034d05d58fb6e03e555b35534824748db021100a95eb69078d6e0f74"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND path = $2 AND trigger_kind = 'postgres'::trigger_kind",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9899f094cad88c4eee7ff4dff0a054bf3247a8a83d2edc8104b0c6213c5cf4b5"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext('datatable_database_permissions_owner:' || $1))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9deb2c34f0dd9a3c35d48b671b9704fbda8f4429f9c1c97a3f6e6a80d39d335c"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path, trigger_config->>'postgres_resource_path' AS \"postgres_resource_path!\"\n FROM capture_config\n WHERE trigger_kind = 'postgres'::trigger_kind\n AND trigger_config->>'postgres_resource_path' LIKE 'datatable://%'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "postgres_resource_path!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a05e98be76846ebdb197f002d189060ce2030314e946ca5d6360002cfb1bcbba"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT permissioned_as, permissioned_as_email FROM v2_job 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": "a44a4daf82889cfbec961702f7eb83d015a2a6b1b23fcfbd18e7f2ea36e4d8fc"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "af0bcc6aaf455f75cdf44a503eed708f200a52aaf55c7cf40f7b210f97688c15"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name AS \"name!\" FROM group_ WHERE workspace_id = $1 AND name = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d4457794adcb511b71dd478bb7c421ac2494716d76af6f2e0847f17224a8e72a"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM datatable_database_permissions WHERE database_key = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d51b957f116e7292b3c5bb955e5d1deb08846598ac67edb454c85b7000a2bd0b"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "da5da57ea48ddc6ab271b6c18baa5f7360008e082e2fb8d58faff4461e18c83a"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT jsonb_object_keys(datatable->'datatables') FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jsonb_object_keys",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e5810ac68c61dd1219ac247e845521837f4db8f653e1e353edb2d6c9ddb7a781"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ec849eb2e6bf2d5d13566721ea8d92b1aaa9a043706fbcb85e39f1dd94fe91ab"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
313c572c9dcbcaafd8a1594df4054f9dd26f395c
|
||||
3579765f3b36a339b9de2a2e8186bcfb681732d9
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS datatable_database_permissions;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Role-based access to a data table's database is a property of the Postgres
|
||||
-- database the roles are created in, not of the config entry that points at it:
|
||||
-- every entry reaching the same database, in any workspace, resolves to this row.
|
||||
-- database_key: 'instance:<dbname>' for an instance database, 'pg:<sha256 of
|
||||
-- host, port and dbname>' for a resource-backed one. The tenants named in
|
||||
-- `permissions` are principals of owner_workspace_id, and only its admins manage
|
||||
-- the row. A row whose owner was deleted keeps governing its database with no
|
||||
-- owner: every role is refused and only a superadmin reaches it, until one opts
|
||||
-- out or saves it from a workspace that then becomes the owner.
|
||||
CREATE TABLE datatable_database_permissions (
|
||||
database_key TEXT PRIMARY KEY,
|
||||
owner_workspace_id VARCHAR(50) REFERENCES workspace(id) ON DELETE SET NULL,
|
||||
permissions JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX datatable_database_permissions_owner ON datatable_database_permissions (owner_workspace_id);
|
||||
|
||||
GRANT ALL ON datatable_database_permissions TO windmill_user;
|
||||
GRANT ALL ON datatable_database_permissions TO windmill_admin;
|
||||
@@ -1542,6 +1542,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The role selects which postgres login the ATTACH connects as; the asset
|
||||
/// is still the data table, so it must not leak into the recorded path.
|
||||
#[test]
|
||||
fn test_sql_asset_parser_strips_role_from_datatable_ref() {
|
||||
for (attach, expected) in [
|
||||
("ATTACH 'datatable://my_dt?role=analyst' AS dt;", "my_dt"),
|
||||
("ATTACH 'datatable?role=analyst' AS dt;", "main"),
|
||||
] {
|
||||
let input = format!("{attach}\nINSERT INTO dt.table1 VALUES ('test');");
|
||||
let s = parse_assets(&input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: format!("{expected}/table1"),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},]),
|
||||
"{attach}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_create_table() {
|
||||
let input = r#"
|
||||
|
||||
@@ -728,10 +728,17 @@ pub fn parse_asset_syntax(
|
||||
s: &str,
|
||||
enable_default_syntax: bool,
|
||||
) -> Option<(AssetKind, Cow<'_, str>)> {
|
||||
if enable_default_syntax && s == "datatable" {
|
||||
return Some((AssetKind::DataTable, Cow::Borrowed("main")));
|
||||
} else if enable_default_syntax && s == "ducklake" {
|
||||
return Some((AssetKind::Ducklake, Cow::Borrowed("main")));
|
||||
if enable_default_syntax {
|
||||
// `datatable` and `datatable?role=<role>` both name the default data
|
||||
// table; the role picks which postgres login the ATTACH connects as and
|
||||
// is not part of the asset's identity.
|
||||
if s.strip_prefix("datatable")
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('?'))
|
||||
{
|
||||
return Some((AssetKind::DataTable, Cow::Borrowed("main")));
|
||||
} else if s == "ducklake" {
|
||||
return Some((AssetKind::Ducklake, Cow::Borrowed("main")));
|
||||
}
|
||||
}
|
||||
for (prefix, kind) in ASSET_KINDS.iter() {
|
||||
if s.starts_with(prefix) {
|
||||
@@ -739,6 +746,14 @@ pub fn parse_asset_syntax(
|
||||
if *kind == AssetKind::Dbt {
|
||||
return Some((*kind, Cow::Owned(canonicalize_table_asset_path(suffix))));
|
||||
}
|
||||
// Same for the explicit form: `datatable://<name>?role=<role>` is
|
||||
// still the `<name>` data table. Only data tables take a query
|
||||
// string — for a Resource, `?table=` is part of the path.
|
||||
if *kind == AssetKind::DataTable {
|
||||
if let Some((path, _)) = 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
|
||||
|
||||
@@ -800,6 +800,16 @@ async fn delete_folder(
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
|
||||
// A data table role names its tenants by principal, so the name is free
|
||||
// after this — and recreating a folder with it would inherit every role the
|
||||
// old one could run as.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
|
||||
&w_id,
|
||||
&format!("f/{name}"),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let del = sqlx::query_scalar!(
|
||||
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1",
|
||||
name,
|
||||
|
||||
@@ -794,6 +794,16 @@ async fn delete_group(
|
||||
}
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
|
||||
// A data table role names its tenants by principal, so the name is free
|
||||
// after the deletes below — and recreating a group with it would inherit
|
||||
// every role the old one could run as.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
|
||||
&w_id,
|
||||
&format!("g/{name}"),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2",
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {token}"))
|
||||
}
|
||||
|
||||
const MAIN_KEY: &str = "instance:dt_main";
|
||||
|
||||
/// A data table `main` on the instance database `dt_main`, in `w`.
|
||||
async fn plant_main(db: &Pool<Postgres>, w: &str) {
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ($1, $2)
|
||||
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(json!({
|
||||
"datatables": {
|
||||
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } }
|
||||
}
|
||||
}))
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Permissions on a database, owned by `owner`, with one `analyst` role.
|
||||
async fn plant_permissions(db: &Pool<Postgres>, key: &str, owner: &str, analyst_tenants: &[&str]) {
|
||||
sqlx::query(
|
||||
"INSERT INTO datatable_database_permissions (database_key, owner_workspace_id, permissions)
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(owner)
|
||||
.bind(json!({ "enabled": true, "roles": {
|
||||
"admin": { "tenants": [] },
|
||||
"analyst": { "tenants": analyst_tenants, "pg_rolename": "wm_analyst_x", "pg_password": "pw" }
|
||||
}}))
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Who the `analyst` role of `key` currently lets run as it.
|
||||
async fn tenants(db: &Pool<Postgres>, key: &str) -> Vec<String> {
|
||||
let value: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT permissions->'roles'->'analyst'->'tenants'
|
||||
FROM datatable_database_permissions WHERE database_key = $1",
|
||||
)
|
||||
.bind(key)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
|
||||
async fn usable_roles(port: u16, w: &str, datatable: &str, token: &str) -> serde_json::Value {
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/w/{w}/workspaces/datatable_usable_roles/{datatable}"
|
||||
)),
|
||||
token,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap());
|
||||
resp.json().await.unwrap()
|
||||
}
|
||||
|
||||
/// A tenant is a name, and a name outlives the principal that held it: whoever
|
||||
/// takes it next would run as the role it still names. Every route that frees
|
||||
/// one has to take it off the role, which is easy to miss from any single one of
|
||||
/// them — so they are pinned together.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn freeing_a_principal_takes_its_datatable_tenant(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
plant_main(&db, "test-workspace").await;
|
||||
plant_permissions(
|
||||
&db,
|
||||
MAIN_KEY,
|
||||
"test-workspace",
|
||||
&[
|
||||
"*",
|
||||
"u/test-user-2",
|
||||
"u/test-user-3",
|
||||
"g/leaving_group",
|
||||
"f/leaving_folder",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
for (endpoint, body) in [
|
||||
("groups/create", json!({ "name": "leaving_group" })),
|
||||
("folders/create", json!({ "name": "leaving_folder" })),
|
||||
] {
|
||||
let resp = authed(client().post(format!("{ws}/{endpoint}")), "SECRET_TOKEN")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{endpoint}: {}", resp.text().await?);
|
||||
}
|
||||
|
||||
let resp = authed(
|
||||
client().delete(format!("{ws}/groups/delete/leaving_group")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "delete group: {}", resp.text().await?);
|
||||
assert!(!tenants(&db, MAIN_KEY)
|
||||
.await
|
||||
.contains(&"g/leaving_group".to_string()));
|
||||
|
||||
let resp = authed(
|
||||
client().delete(format!("{ws}/folders/delete/leaving_folder")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "delete folder: {}", resp.text().await?);
|
||||
assert!(!tenants(&db, MAIN_KEY)
|
||||
.await
|
||||
.contains(&"f/leaving_folder".to_string()));
|
||||
|
||||
// Leaving frees the username as surely as an admin removing the member does.
|
||||
let resp = authed(client().post(format!("{ws}/users/leave")), "SECRET_TOKEN_2")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?);
|
||||
assert!(!tenants(&db, MAIN_KEY)
|
||||
.await
|
||||
.contains(&"u/test-user-2".to_string()));
|
||||
|
||||
let resp = authed(
|
||||
client().delete(format!(
|
||||
"http://localhost:{port}/api/users/delete/test3@windmill.dev"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "global delete: {}", resp.text().await?);
|
||||
assert!(!tenants(&db, MAIN_KEY)
|
||||
.await
|
||||
.contains(&"u/test-user-3".to_string()));
|
||||
|
||||
// What no deletion named is left alone — the wildcard above all, which is
|
||||
// not a principal and cannot be freed.
|
||||
assert_eq!(tenants(&db, MAIN_KEY).await, vec!["*".to_string()]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The permissions are the database's, so a fork's copy of the data table reaches
|
||||
/// the same roles — evaluated as a member of the workspace that owns them. Being
|
||||
/// admin of the fork, which any member is of a fork they made, counts for
|
||||
/// nothing; a superadmin reaches every role from anywhere; and the roles are
|
||||
/// managed from the owning workspace alone.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn a_fork_copy_is_evaluated_as_a_member_of_the_owning_workspace(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
plant_main(&db, "test-workspace").await;
|
||||
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
||||
VALUES ('wm-fork-t', 'wm-fork-t', 'test2@windmill.dev', 'test-workspace')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
plant_main(&db, "wm-fork-t").await;
|
||||
// test-user-2 made the fork and is admin of it, and is no member of the parent.
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
||||
('wm-fork-t', 'test2@windmill.dev', 'test-user-2', true, 'Admin'),
|
||||
('wm-fork-t', 'test3@windmill.dev', 'test-user-3', false, 'User')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let fork_admin = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN_2").await;
|
||||
assert_eq!(fork_admin["enabled"], json!(true));
|
||||
assert_eq!(fork_admin["roles"], json!([]), "{fork_admin}");
|
||||
|
||||
let tenant = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(tenant["roles"], json!(["analyst"]), "{tenant}");
|
||||
|
||||
let superadmin = usable_roles(port, "wm-fork-t", "main", "SECRET_TOKEN").await;
|
||||
assert_eq!(
|
||||
superadmin["roles"],
|
||||
json!(["admin", "analyst"]),
|
||||
"{superadmin}"
|
||||
);
|
||||
|
||||
// Managed from the owning workspace: the fork's admin reads them, changes nothing.
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_permissions/main"
|
||||
)),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let info: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(info["owner_workspace_id"], json!("test-workspace"));
|
||||
assert_eq!(info["editable"], json!(false));
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/wm-fork-t/workspaces/datatable_permissions/main"
|
||||
)),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.json(&json!({ "enabled": true, "roles": [
|
||||
{ "name": "admin", "tenants": [] }, { "name": "analyst", "tenants": ["u/test-user-2"] }
|
||||
]}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await?;
|
||||
assert_eq!(status, 401, "{text}");
|
||||
assert!(
|
||||
text.contains("managed from workspace 'test-workspace'"),
|
||||
"{text}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A resource-backed database is the host, port and database its resource
|
||||
/// resolves to: entries naming it under other paths and other logins, in other
|
||||
/// workspaces, reach the same permissions; a resource pointed at another database
|
||||
/// reaches none.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn entries_reaching_one_database_share_its_permissions(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
||||
VALUES ('other', 'test3@windmill.dev', 'user-three', false, 'User')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let plant = |w: &'static str, path: &'static str, user: &'static str, dbname: &'static str| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
sqlx::query(
|
||||
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by, edited_at)
|
||||
VALUES ($1, $2, $3, 'postgresql', 'test-user', now())",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(path)
|
||||
.bind(json!({ "host": "db.example", "port": 5432, "dbname": dbname, "user": user, "password": "pw" }))
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ($1, $2)
|
||||
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(json!({ "datatables": {
|
||||
"byo": { "database": { "resource_type": "postgresql", "resource_path": path } }
|
||||
}}))
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
plant("test-workspace", "u/test-user/pg", "app", "prod").await;
|
||||
plant("other", "f/moved/pg", "postgres", "prod").await;
|
||||
|
||||
let key = windmill_common::workspaces::datatable_database_key(
|
||||
&windmill_common::workspaces::DataTableDatabase {
|
||||
resource_type: windmill_common::workspaces::DataTableCatalogResourceType::Postgresql,
|
||||
resource_path: "u/test-user/pg".to_string(),
|
||||
},
|
||||
&json!({ "host": "db.example", "port": 5432, "dbname": "prod" }),
|
||||
);
|
||||
plant_permissions(&db, &key, "test-workspace", &["u/test-user-3"]).await;
|
||||
|
||||
// test-user-3 is `test-user-3` in the owning workspace and `user-three` in
|
||||
// `other`: the tenant is matched where it was written.
|
||||
let from_other = usable_roles(port, "other", "byo", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(from_other["enabled"], json!(true));
|
||||
assert_eq!(from_other["roles"], json!(["analyst"]), "{from_other}");
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE resource SET value = jsonb_set(value, '{dbname}', '\"staging\"')
|
||||
WHERE workspace_id = 'other' AND path = 'f/moved/pg'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let elsewhere = usable_roles(port, "other", "byo", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(elsewhere["enabled"], json!(false), "{elsewhere}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The permissions a workspace owns follow it through a change of its id.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn a_rename_moves_the_permissions_it_owns(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
plant_main(&db, "test-workspace").await;
|
||||
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "new_id": "renamed-ws", "new_name": "Renamed" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
let owner: String = sqlx::query_scalar(
|
||||
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
|
||||
)
|
||||
.bind(MAIN_KEY)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(owner, "renamed-ws");
|
||||
let roles = usable_roles(port, "renamed-ws", "main", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fork that keeps the original copies the parent's entry verbatim, `forked_from`
|
||||
/// included when the parent's own entry is a clone. The stamp means "cloned into
|
||||
/// this workspace's own database" — it is what lets the fork's deletion drop that
|
||||
/// database — so a copy must not carry one, and the config form may update the
|
||||
/// schema snapshot inside an existing stamp but never add or remove one.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn a_kept_original_does_not_inherit_the_clone_stamp(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.bind(json!({
|
||||
"datatables": {
|
||||
"byo": {
|
||||
"database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" },
|
||||
"forked_from": { "schema": {} }
|
||||
}
|
||||
}
|
||||
}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/create_fork")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "id": "wm-fork-kept", "name": "kept" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
let copy: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = 'wm-fork-kept'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(copy["database"]["resource_path"], json!("u/test-user/pg"));
|
||||
assert!(copy.get("forked_from").is_none(), "{copy}");
|
||||
|
||||
let save = |w: &'static str, datatables: serde_json::Value| async move {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/{w}/workspaces/edit_datatable_config"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "settings": { "datatables": datatables } }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap());
|
||||
};
|
||||
let stamp = |w: &'static str| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
let entry: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
entry["forked_from"].clone()
|
||||
}
|
||||
};
|
||||
let byo =
|
||||
json!({ "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" } });
|
||||
// The form cannot stamp the copy...
|
||||
save(
|
||||
"wm-fork-kept",
|
||||
json!({ "byo": {
|
||||
"database": byo["database"], "forked_from": { "schema": {} }
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stamp("wm-fork-kept").await, serde_json::Value::Null);
|
||||
// ...nor take the parent's stamp away, and it may update the snapshot inside it.
|
||||
save("test-workspace", json!({ "byo": byo })).await;
|
||||
assert_eq!(stamp("test-workspace").await, json!({ "schema": {} }));
|
||||
save(
|
||||
"test-workspace",
|
||||
json!({ "byo": {
|
||||
"database": byo["database"], "forked_from": { "schema": { "t": ["id"] } }
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
stamp("test-workspace").await,
|
||||
json!({ "schema": { "t": ["id"] } })
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A save carries the whole role list the drawer loaded, so it can name a tenant
|
||||
/// another admin's deletion took off the role in between, and it can leave a
|
||||
/// role stored migrations still carry in their `-- role` annotation undefined.
|
||||
/// Both are refused rather than written; turning permissions off is not.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn a_save_names_only_what_exists(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
plant_main(&db, "test-workspace").await;
|
||||
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user"]).await;
|
||||
// Spelled the way the parser accepts and a `-- role ` search would miss.
|
||||
sqlx::query(
|
||||
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down)
|
||||
VALUES ('test-workspace', 'main', 1, 'add_orders', '--role analyst\nCREATE TABLE orders ()', NULL)",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let preview = |body: serde_json::Value| {
|
||||
let ws = ws.clone();
|
||||
async move {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{ws}/workspaces/datatable_permissions/main/preview"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
(resp.status().as_u16(), resp.text().await.unwrap())
|
||||
}
|
||||
};
|
||||
|
||||
let (status, text) = preview(json!({ "enabled": true, "roles": [
|
||||
{ "name": "admin", "tenants": [] },
|
||||
{ "name": "analyst", "tenants": ["u/test-user", "u/ghost", "g/nobody", "f/nowhere", "*"] }
|
||||
]}))
|
||||
.await;
|
||||
assert_eq!(status, 400, "{text}");
|
||||
assert!(text.contains("f/nowhere, g/nobody, u/ghost"), "{text}");
|
||||
|
||||
let (status, text) = preview(json!({ "enabled": true,
|
||||
"roles": [{ "name": "admin", "tenants": [] }, { "name": "reader", "tenants": ["u/test-user"] }],
|
||||
"renames": [{ "from": "analyst", "to": "reader" }]
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(status, 400, "{text}");
|
||||
assert!(text.contains("'add_orders' (role 'analyst')"), "{text}");
|
||||
|
||||
let (status, text) = preview(json!({ "enabled": true,
|
||||
"roles": [{ "name": "admin", "tenants": [] }]
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(status, 400, "{text}");
|
||||
assert!(text.contains("'add_orders' (role 'analyst')"), "{text}");
|
||||
|
||||
// The roles are the database's: a migration of another entry reaching it,
|
||||
// here an alias in the same workspace, is stranded all the same.
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, '{datatables,alias}',
|
||||
'{"database": {"resource_type": "instance", "resource_path": "dt_main"}}')
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE datatable_migrations SET datatable = 'alias' WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let (status, text) = preview(json!({ "enabled": true,
|
||||
"roles": [{ "name": "admin", "tenants": [] }]
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(status, 400, "{text}");
|
||||
assert!(
|
||||
text.contains("test-workspace/alias: 'add_orders' (role 'analyst')"),
|
||||
"{text}"
|
||||
);
|
||||
sqlx::query("DELETE FROM datatable_migrations WHERE workspace_id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Turning permissions off ignores the submitted roles and is never refused,
|
||||
// stale tenant or not: it gets as far as the database this test lacks.
|
||||
let (_, text) = preview(json!({ "enabled": false, "roles": [
|
||||
{ "name": "admin", "tenants": [] },
|
||||
{ "name": "analyst", "tenants": ["u/ghost"] }
|
||||
]}))
|
||||
.await;
|
||||
assert!(
|
||||
!text.contains("no longer exist") && !text.contains("Migration(s)"),
|
||||
"{text}"
|
||||
);
|
||||
|
||||
// The same save with what exists gets past both checks, to the database this
|
||||
// test does not have.
|
||||
let (_, text) = preview(json!({ "enabled": true, "roles": [
|
||||
{ "name": "admin", "tenants": [] },
|
||||
{ "name": "analyst", "tenants": ["u/test-user", "*"] }
|
||||
]}))
|
||||
.await;
|
||||
assert!(
|
||||
!text.contains("no longer exist") && !text.contains("Migration(s)"),
|
||||
"{text}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deleting a workspace takes only the roles it owns with it. A fork that held a
|
||||
/// copy of the parent's data table owns nothing there, so its deletion leaves the
|
||||
/// parent's row alone; the owner's own deletion drops its logins but leaves the
|
||||
/// row, ownerless and closed: every role refused, only a superadmin through.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn a_deletion_takes_only_the_permissions_it_owns(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
plant_main(&db, "test-workspace").await;
|
||||
plant_permissions(&db, MAIN_KEY, "test-workspace", &["u/test-user-3"]).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
||||
VALUES ('wm-fork-t', 'wm-fork-t', 'test2@windmill.dev', 'test-workspace')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
plant_main(&db, "wm-fork-t").await;
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
||||
('wm-fork-t', 'test2@windmill.dev', 'test-user-2', true, 'Admin'),
|
||||
('wm-fork-t', 'test3@windmill.dev', 'test-user-3', false, 'User')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// The fork's owner deletes it: the parent's row is untouched.
|
||||
let resp = authed(
|
||||
client().delete(format!(
|
||||
"http://localhost:{port}/api/workspaces/delete/wm-fork-t"
|
||||
)),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let owner: Option<String> = sqlx::query_scalar(
|
||||
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
|
||||
)
|
||||
.bind(MAIN_KEY)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(owner.as_deref(), Some("test-workspace"));
|
||||
let roles = usable_roles(port, "test-workspace", "main", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
|
||||
|
||||
// Another workspace reaching the same database, then the owner is deleted.
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace (id, name, owner) VALUES ('elsewhere', 'elsewhere', 'test-user')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
plant_main(&db, "elsewhere").await;
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
||||
VALUES ('elsewhere', 'test3@windmill.dev', 'test-user-3', true, 'Admin')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().delete(format!(
|
||||
"http://localhost:{port}/api/workspaces/delete/test-workspace"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let owner: Option<String> = sqlx::query_scalar(
|
||||
"SELECT owner_workspace_id FROM datatable_database_permissions WHERE database_key = $1",
|
||||
)
|
||||
.bind(MAIN_KEY)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(owner, None);
|
||||
// Admin of `elsewhere`, and the tenant the row names: neither counts without an owner.
|
||||
let closed = usable_roles(port, "elsewhere", "main", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(closed["enabled"], json!(true));
|
||||
assert_eq!(closed["roles"], json!([]), "{closed}");
|
||||
let superadmin = usable_roles(port, "elsewhere", "main", "SECRET_TOKEN").await;
|
||||
assert_eq!(
|
||||
superadmin["roles"],
|
||||
json!(["admin", "analyst"]),
|
||||
"{superadmin}"
|
||||
);
|
||||
// Nobody but a superadmin manages it now: an admin of `elsewhere` reads it, changes nothing.
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/w/elsewhere/workspaces/datatable_permissions/main"
|
||||
)),
|
||||
"SECRET_TOKEN_3",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await?;
|
||||
assert_eq!(status, 200, "{text}");
|
||||
let info: serde_json::Value = serde_json::from_str(&text)?;
|
||||
assert_eq!(info["editable"], json!(false), "{info}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An export carries a database's roles and tenants, never its login names or
|
||||
/// passwords; importing them governs a database nobody governs yet, owned by
|
||||
/// the importing workspace, with every role refused until a save creates the
|
||||
/// logins. A database already governed is left alone and reported.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn imported_permissions_govern_without_logins(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('test-workspace', $1)
|
||||
ON CONFLICT (workspace_id) DO UPDATE SET datatable = EXCLUDED.datatable",
|
||||
)
|
||||
.bind(json!({ "datatables": {
|
||||
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } },
|
||||
"other": { "database": { "resource_type": "instance", "resource_path": "dt_other" } }
|
||||
}}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
plant_permissions(&db, "instance:dt_other", "test-workspace", &["*"]).await;
|
||||
let import = |rows: serde_json::Value| {
|
||||
let ws = ws.clone();
|
||||
async move {
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/datatable_permissions_import")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&rows)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await.unwrap();
|
||||
(status, text)
|
||||
}
|
||||
};
|
||||
let exported = json!([
|
||||
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
|
||||
"admin": { "tenants": [] },
|
||||
"analyst": { "tenants": ["u/test-user-3"], "pg_rolename": "wm_analyst_x", "pg_password": "leaked?" }
|
||||
}}},
|
||||
{ "datatable": "other", "permissions": { "enabled": true, "roles": { "admin": { "tenants": [] } } } }
|
||||
]);
|
||||
let (status, text) = import(exported).await;
|
||||
assert_eq!(status, 200, "{text}");
|
||||
assert_eq!(text, "[\"other\"]");
|
||||
// A database is named through a data table of this workspace, never by key.
|
||||
let (status, text) = import(json!([
|
||||
{ "datatable": "nope", "permissions": { "enabled": true, "roles": { "admin": { "tenants": [] } } } }
|
||||
]))
|
||||
.await;
|
||||
assert_eq!(status, 404, "{text}");
|
||||
// An archive's tenant whose principal is gone is refused like a save's would be.
|
||||
sqlx::query("DELETE FROM datatable_database_permissions WHERE database_key = $1")
|
||||
.bind(MAIN_KEY)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let (status, text) = import(json!([
|
||||
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
|
||||
"admin": { "tenants": ["u/ghost"] }
|
||||
}}}
|
||||
]))
|
||||
.await;
|
||||
assert_eq!(status, 400, "{text}");
|
||||
assert!(text.contains("u/ghost"), "{text}");
|
||||
let (status, text) = import(json!([
|
||||
{ "datatable": "main", "permissions": { "enabled": true, "roles": {
|
||||
"admin": { "tenants": [] },
|
||||
"analyst": { "tenants": ["u/test-user-3"], "pg_rolename": "wm_analyst_x", "pg_password": "leaked?" }
|
||||
}}}
|
||||
]))
|
||||
.await;
|
||||
assert_eq!(status, 200, "{text}");
|
||||
|
||||
let row: (Option<String>, serde_json::Value) = sqlx::query_as(
|
||||
"SELECT owner_workspace_id, permissions FROM datatable_database_permissions WHERE database_key = $1",
|
||||
)
|
||||
.bind(MAIN_KEY)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.0.as_deref(), Some("test-workspace"));
|
||||
// A login name is a cluster-wide identifier the next save would rename or
|
||||
// reset, so it is not taken from an import either.
|
||||
assert!(
|
||||
row.1["roles"]["analyst"].get("pg_rolename").is_none(),
|
||||
"{}",
|
||||
row.1
|
||||
);
|
||||
assert!(
|
||||
row.1["roles"]["analyst"].get("pg_password").is_none(),
|
||||
"{}",
|
||||
row.1
|
||||
);
|
||||
// The tenant is listed as usable; resolving the role, which has no login
|
||||
// yet, gives the caller nothing rather than the owning connection.
|
||||
let roles = usable_roles(port, "test-workspace", "main", "SECRET_TOKEN_3").await;
|
||||
assert_eq!(roles["roles"], json!(["analyst"]), "{roles}");
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"{ws}/workspaces/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=analyst"
|
||||
)),
|
||||
"SECRET_TOKEN_3",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await?;
|
||||
assert_eq!(status, 401, "{text}");
|
||||
assert!(text.contains("has no login yet"), "{text}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A clone of a permissioned data table would be a database of its own that
|
||||
/// nothing governs, readable in full by every member of the fork: refused at the
|
||||
/// one place a caller cannot go around, the fork creation that wires it in.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn a_governed_datatable_cannot_be_cloned_into_a_fork(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
plant_main(&db, "test-workspace").await;
|
||||
plant_permissions(&db, MAIN_KEY, "test-workspace", &["*"]).await;
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/create_fork"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"id": "wm-fork-clone", "name": "clone",
|
||||
"forked_datatables": [{ "name": "main", "new_dbname": "wm_fork_clone_main" }]
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await?;
|
||||
assert_eq!(status, 400, "{text}");
|
||||
assert!(text.contains("cannot be cloned"), "{text}");
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM workspace WHERE id = 'wm-fork-clone')")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(!exists);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1681,6 +1681,28 @@ async fn delete_user(
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// A username is scoped to one workspace, and so are the tenants naming it, so
|
||||
// the memberships are read before anything is deleted. Ordered, and before
|
||||
// every other write of this transaction — see
|
||||
// `lock_datatable_permissions_unchecked`.
|
||||
let memberships = sqlx::query!(
|
||||
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
|
||||
&email_to_delete
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for row in &memberships {
|
||||
// The username is free in that workspace once the rows below are gone, so
|
||||
// a role still naming it would hand itself to whoever takes it next.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
|
||||
&row.workspace_id,
|
||||
&format!("u/{}", row.username),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1689,14 +1711,12 @@ 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",
|
||||
&email_to_delete
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM usr WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for username in usernames {
|
||||
for row in memberships {
|
||||
let username = row.username;
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -2421,6 +2441,18 @@ pub async fn delete_workspace_user_internal(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
authed: Option<&ApiAuthed>, // None for system operations
|
||||
) -> Result<()> {
|
||||
// ---- Clean up data table role tenants ----
|
||||
|
||||
// The username is free once this user's rows are gone, so a tenant left
|
||||
// behind would hand every role it names to whoever is invited into it next.
|
||||
// First in the transaction — see `lock_datatable_permissions_unchecked`.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
|
||||
w_id,
|
||||
&format!("u/{username_to_delete}"),
|
||||
tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- Clean up extra_perms referencing this user ----
|
||||
let extra_perms_tables = [
|
||||
"script",
|
||||
@@ -2578,6 +2610,11 @@ async fn delete_workspace_user(
|
||||
) -> Result<String> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Before the `usr` row below — see `lock_datatable_permissions_unchecked`. The
|
||||
// removal itself takes this row too; re-acquiring it inside a transaction
|
||||
// costs nothing.
|
||||
windmill_common::workspaces::lock_datatable_permissions_unchecked(&mut tx, &w_id).await?;
|
||||
|
||||
// Locked so that the authorization below and the delete it guards see the same row.
|
||||
let target = sqlx::query!(
|
||||
"SELECT email, is_admin FROM usr where username = $1 AND workspace_id = $2 FOR UPDATE",
|
||||
@@ -3427,6 +3464,16 @@ async fn leave_workspace(
|
||||
) -> Result<String> {
|
||||
forbid_job_token_account_destruction(&authed)?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Leaving frees the username here too, and a role that still names it would
|
||||
// be inherited by the next member to take it.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
|
||||
&w_id,
|
||||
&format!("u/{}", authed.username),
|
||||
&mut tx,
|
||||
)
|
||||
.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,38 @@
|
||||
/*
|
||||
* 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.
|
||||
//!
|
||||
//! Reading who owns what stays open; every owner change and every grant is the
|
||||
//! plan this returns, so an edition that is not enterprise cannot make one.
|
||||
//! `private` alone is not that edition: community builds carry it, so the
|
||||
//! planner is behind `enterprise` as well.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[allow(unused)]
|
||||
pub(crate) use crate::datatable_acl_ee::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
use {
|
||||
crate::datatable_acl::{AclChange, AclPlan, AclTarget, OwnedObject},
|
||||
windmill_common::error::{Error, Result},
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn plan_statements(
|
||||
_target: &AclTarget,
|
||||
_change: &AclChange,
|
||||
_dbname: &str,
|
||||
_pg_role: &str,
|
||||
_other_pg_roles: &[String],
|
||||
_existing_objects: &[OwnedObject],
|
||||
) -> Result<AclPlan> {
|
||||
Err(Error::BadRequest(
|
||||
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -37,7 +37,7 @@ use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
|
||||
use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, DebouncingSettings};
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::worker::{to_raw_value, SqlAnnotations};
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||||
use windmill_common::{PgDatabase, DB};
|
||||
use windmill_git_sync::{
|
||||
@@ -128,9 +128,38 @@ async fn datatable_database_arg(
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?;
|
||||
|
||||
// No role in the reference: a migration carries its own as a `-- role <name>`
|
||||
// annotation at the top of its SQL, which the executor reads. Absent, the
|
||||
// executor falls back to the data table's default role.
|
||||
Ok(to_raw_value(&format!("datatable://{datatable_name}")))
|
||||
}
|
||||
|
||||
/// Refuse to run a migration whose `-- role` annotation names a role the caller
|
||||
/// may not use.
|
||||
///
|
||||
/// The executor checks this too when it resolves the connection, so this is not
|
||||
/// the boundary — it is what makes the refusal legible (which migration, which
|
||||
/// role) and stops it before a job is pushed or a version recorded.
|
||||
async fn ensure_migration_role_allowed(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
sql: &str,
|
||||
timestamp: i64,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_permissions::ensure_can_use_datatable_role(
|
||||
db,
|
||||
w_id,
|
||||
datatable_name,
|
||||
SqlAnnotations::datatable_role(sql).as_deref(),
|
||||
authed,
|
||||
&format!("Migration {timestamp} ({name})"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as
|
||||
/// the requesting user and labelled `datatable_migration` for traceability, then
|
||||
/// wait for it. Errors if the job fails.
|
||||
@@ -426,7 +455,7 @@ async fn run_datatable_migrations(
|
||||
"all"
|
||||
};
|
||||
|
||||
let mut applied = Vec::new();
|
||||
let mut to_run = Vec::new();
|
||||
for m in migrations {
|
||||
if let Some(only) = query.only {
|
||||
// Run a single specific migration, skipping every other one.
|
||||
@@ -440,6 +469,28 @@ async fn run_datatable_migrations(
|
||||
if applied_versions.contains(&m.timestamp) {
|
||||
continue;
|
||||
}
|
||||
to_run.push(m);
|
||||
}
|
||||
|
||||
// Fail the batch rather than skip: a migration the caller may not run is a
|
||||
// gap in an ordered sequence, and silently leaving it out would apply later
|
||||
// ones on top of a schema that never got this change. Checked for the whole
|
||||
// batch first, so the refusal does not land half way through it.
|
||||
for m in to_run.iter() {
|
||||
ensure_migration_role_allowed(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&m.code_up,
|
||||
m.timestamp,
|
||||
&m.name,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut applied = Vec::new();
|
||||
for m in to_run {
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -589,6 +640,16 @@ async fn rollback_datatable_migrations(
|
||||
})?;
|
||||
|
||||
let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?;
|
||||
ensure_migration_role_allowed(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&code_down,
|
||||
version,
|
||||
&definition.name,
|
||||
)
|
||||
.await?;
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &code_down)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -808,8 +869,7 @@ async fn require_datatable_migrations_manager(db: &DB, authed: &ApiAuthed) -> Re
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(
|
||||
"Only workspace admins and super admins can enable or disable data table migrations"
|
||||
.to_string(),
|
||||
"Only workspace admins and super admins can manage data table migrations".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -837,6 +897,12 @@ async fn enable_datatable_migrations(
|
||||
)));
|
||||
}
|
||||
|
||||
// Opting in to migrations is one of the places an admin passes through, and
|
||||
// an instance database provisioned before the grants carried their options
|
||||
// cannot hand privileges to the roles permissions create.
|
||||
crate::datatable_permissions::ensure_instance_db_can_delegate(&db, &w_id, &datatable_name)
|
||||
.await;
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
@@ -1179,32 +1245,23 @@ async fn delete_datatable_migration(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>,
|
||||
) -> Result<String> {
|
||||
// Hold the run-serialization lock across the applied-check and the delete: a
|
||||
// run snapshots a migration's SQL before recording its version, so an
|
||||
// unserialized delete could race it and leave `_wm_migrations` pointing at a
|
||||
// definition that no longer exists (breaking rollback and hiding the applied
|
||||
// version). Held until the handler returns. Fail closed if we can't verify.
|
||||
let unreachable = |e| {
|
||||
Error::internal_err(format!(
|
||||
"Cannot verify whether migration {} on data table '{}' has already been applied \
|
||||
(its database is unreachable: {}). Refusing to delete it; retry once the database \
|
||||
is reachable.",
|
||||
timestamp, datatable_name, e
|
||||
))
|
||||
};
|
||||
// Hold the run-serialization lock across the delete: a run snapshots a
|
||||
// migration's SQL before recording its version, so an unserialized delete
|
||||
// could race it and record a version whose definition is already gone.
|
||||
// Held until the handler returns.
|
||||
//
|
||||
// Deleting one that has already run is allowed: it leaves `_wm_migrations`
|
||||
// naming a definition that no longer exists, so it can no longer be
|
||||
// reverted — which is what the caller is warned about before asking.
|
||||
let lock_client = lock_datatable_migration_runs(&db, &w_id, &datatable_name)
|
||||
.await
|
||||
.map_err(unreachable)?;
|
||||
let applied = read_applied_versions_on_client(&lock_client, &datatable_name)
|
||||
.await
|
||||
.map_err(unreachable)?;
|
||||
if applied.contains(×tamp) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Migration {} on data table '{}' has already been applied and cannot be deleted. \
|
||||
Revert it first.",
|
||||
timestamp, datatable_name
|
||||
)));
|
||||
}
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Cannot delete migration {} on data table '{}': its database is unreachable \
|
||||
({}). Retry once the database is reachable.",
|
||||
timestamp, datatable_name, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let deleted_name = sqlx::query_scalar!(
|
||||
"DELETE FROM datatable_migrations \
|
||||
@@ -1431,6 +1488,11 @@ async fn generate_initial_datatable_migration(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<DatatableMigration> {
|
||||
// The snapshot below is taken with the data table's own connection and returns
|
||||
// every object in it, so it answers to the same gate as turning migrations on
|
||||
// rather than to whatever roles the caller may run as: a member who is a
|
||||
// tenant of none would otherwise read the whole schema through it.
|
||||
require_datatable_migrations_manager(&db, &authed).await?;
|
||||
validate_datatable_path_segment(&datatable_name)?;
|
||||
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 role planner comes from: the enterprise one, or a refusal.
|
||||
//!
|
||||
//! Every change to a data table's Postgres roles — creating, renaming, dropping
|
||||
//! them — is the plan this returns, so an edition that is not enterprise cannot
|
||||
//! make one. `private` alone is not that edition: community builds carry it, so
|
||||
//! the planner is behind `enterprise` as well.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[allow(unused)]
|
||||
pub(crate) use crate::datatable_permissions_ee::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
use {
|
||||
crate::datatable_permissions::{
|
||||
DefaultAclRule, PgRoleInventory, RolePlan, SetDatatablePermissions,
|
||||
},
|
||||
windmill_common::error::{Error, Result},
|
||||
windmill_common::workspaces::DataTablePermissions,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn plan_role_changes(
|
||||
_database_key: &str,
|
||||
_dbname: &str,
|
||||
_admin_pg_role: &str,
|
||||
_old: Option<&DataTablePermissions>,
|
||||
_req: &SetDatatablePermissions,
|
||||
_pg_roles: &PgRoleInventory,
|
||||
_public_schema_is_open: bool,
|
||||
_default_acl_rules: &[DefaultAclRule],
|
||||
) -> Result<RolePlan> {
|
||||
Err(Error::BadRequest(
|
||||
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(all(feature = "private", feature = "enterprise"))))]
|
||||
mod tests {
|
||||
/// Compiled in every edition that is not enterprise — community builds
|
||||
/// included, which carry the enterprise sources but must not plan with them.
|
||||
#[test]
|
||||
fn a_non_enterprise_build_plans_nothing() {
|
||||
let plan = super::plan_role_changes(
|
||||
"acme",
|
||||
"main",
|
||||
"db",
|
||||
"admin",
|
||||
None,
|
||||
&serde_json::from_value(serde_json::json!({ "enabled": true })).unwrap(),
|
||||
&Default::default(),
|
||||
false,
|
||||
&[],
|
||||
);
|
||||
assert!(plan
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Windmill Enterprise Edition"));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
pub mod datatable_migrations;
|
||||
pub mod deployment_requests;
|
||||
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;
|
||||
pub mod workspaces_oss;
|
||||
|
||||
// Community builds carry `private`, so the data table planners take the
|
||||
// enterprise feature too: what they plan is enterprise-only.
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_acl_ee;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_permissions_ee;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod workspaces_ee;
|
||||
|
||||
@@ -45,10 +45,11 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked,
|
||||
validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable,
|
||||
DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules,
|
||||
ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db,
|
||||
get_datatable_resource_from_db_unchecked, validate_dev_workspace_id,
|
||||
validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType,
|
||||
DataTableForkBehavior, DatatableAccess, ProtectionRuleKind, ProtectionRules, ProtectionRuleset,
|
||||
RuleCheckResult, WorkspaceGitSyncSettings, ADMIN_DATATABLE_ROLE, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -141,6 +142,8 @@ pub fn workspaced_service() -> Router {
|
||||
get(test_datatable_connection),
|
||||
)
|
||||
.merge(crate::datatable_migrations::routes())
|
||||
.merge(crate::datatable_permissions::routes())
|
||||
.merge(crate::datatable_acl::routes())
|
||||
.route("/git_sync_enabled", get(get_git_sync_enabled))
|
||||
.route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode))
|
||||
.route("/edit_git_sync_config", post(edit_git_sync_config))
|
||||
@@ -1150,6 +1153,9 @@ async fn get_public_settings(
|
||||
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Every workspace member reads this one, so the generated role logins go
|
||||
// through the same redaction as the admin settings and the tarball.
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
@@ -2193,8 +2199,28 @@ struct DataTableTables {
|
||||
datatable_name: String,
|
||||
/// Hierarchical metadata: schema_name -> table_names
|
||||
schemas: TableListMap,
|
||||
/// The schemas the connection's role may create tables in — a subset of
|
||||
/// `schemas`, since reaching one says nothing about writing to it.
|
||||
#[serde(default)]
|
||||
creatable_schemas: Vec<String>,
|
||||
/// Whether that role may create schemas in the database at all.
|
||||
#[serde(default)]
|
||||
can_create_schema: bool,
|
||||
/// The schemas whose owner this role is a member of, and the tables, as
|
||||
/// `schema.table`. Only those can have their access changed, so the rest do
|
||||
/// not offer it.
|
||||
#[serde(default)]
|
||||
manageable_schemas: Vec<String>,
|
||||
#[serde(default)]
|
||||
manageable_tables: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
/// The roles the caller may run this data table as. Empty when it has no
|
||||
/// permissions, which is also what tells the UI it has no role to show.
|
||||
#[serde(default)]
|
||||
usable_roles: Vec<String>,
|
||||
/// The role the caller gets without naming one.
|
||||
default_role: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -2202,6 +2228,8 @@ struct GetDataTableSchemaQuery {
|
||||
datatable_name: String,
|
||||
schema_name: String,
|
||||
table_name: String,
|
||||
/// Read the columns as this role rather than the data table's default one.
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -2248,6 +2276,8 @@ async fn test_datatable_connection(
|
||||
) -> JsonResult<DataTableConnectionCheck> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// The data table's own connection, not whichever role the caller resolves to:
|
||||
// this reports what the data table itself can do, and the page is admin-only.
|
||||
let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?;
|
||||
let pg_db: PgDatabase = serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
|
||||
@@ -2334,7 +2364,7 @@ async fn test_datatable_connection(
|
||||
}
|
||||
|
||||
async fn list_datatable_schemas(
|
||||
_authed: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<DataTableSchema>> {
|
||||
@@ -2342,7 +2372,7 @@ async fn list_datatable_schemas(
|
||||
let mut results = Vec::new();
|
||||
|
||||
for datatable_name in datatable_names {
|
||||
let schema = match get_datatable_schema(&db, &w_id, &datatable_name).await {
|
||||
let schema = match get_datatable_schema(&db, &authed, &w_id, &datatable_name).await {
|
||||
Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None },
|
||||
Err(e) => DataTableSchema {
|
||||
datatable_name,
|
||||
@@ -2356,21 +2386,58 @@ async fn list_datatable_schemas(
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
/// The role the caller is on, and the data table it belongs to. The tree covers
|
||||
/// every data table, and a role name means nothing outside the one it was
|
||||
/// picked on, so it is named rather than applied to all of them.
|
||||
#[derive(Deserialize)]
|
||||
struct ListDataTableTablesQuery {
|
||||
role_for: Option<String>,
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_datatable_tables(
|
||||
_authed: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(query): Query<ListDataTableTablesQuery>,
|
||||
) -> JsonResult<Vec<DataTableTables>> {
|
||||
let datatable_names = list_datatable_names(&db, &w_id).await?;
|
||||
let mut roles = list_datatable_roles(&db, &authed, &w_id).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for datatable_name in datatable_names {
|
||||
let tables = match get_datatable_tables(&db, &w_id, &datatable_name).await {
|
||||
Ok(schemas) => DataTableTables { datatable_name, schemas, error: None },
|
||||
let (usable_roles, default_role) = roles
|
||||
.remove(&datatable_name)
|
||||
.unwrap_or_else(|| (vec![], ADMIN_DATATABLE_ROLE.to_string()));
|
||||
// Privileges are the connected role's, so the answer for the data table
|
||||
// the caller is on has to be read as the role they picked, not as the
|
||||
// one it defaults to.
|
||||
let role = query
|
||||
.role
|
||||
.as_deref()
|
||||
.filter(|_| query.role_for.as_deref() == Some(datatable_name.as_str()));
|
||||
let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name, role).await {
|
||||
Ok(contents) => DataTableTables {
|
||||
datatable_name,
|
||||
schemas: contents.tables,
|
||||
creatable_schemas: contents.creatable_schemas,
|
||||
can_create_schema: contents.can_create_schema,
|
||||
manageable_schemas: contents.manageable_schemas,
|
||||
manageable_tables: contents.manageable_tables,
|
||||
error: None,
|
||||
usable_roles,
|
||||
default_role,
|
||||
},
|
||||
Err(e) => DataTableTables {
|
||||
datatable_name,
|
||||
schemas: HashMap::new(),
|
||||
creatable_schemas: vec![],
|
||||
can_create_schema: false,
|
||||
manageable_schemas: vec![],
|
||||
manageable_tables: vec![],
|
||||
error: Some(e.to_string()),
|
||||
usable_roles,
|
||||
default_role,
|
||||
},
|
||||
};
|
||||
results.push(tables);
|
||||
@@ -2379,18 +2446,55 @@ async fn list_datatable_tables(
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
/// Which roles the caller may use on each data table of the workspace, and the
|
||||
/// one they get by default. The tree lists every data table, so each one is
|
||||
/// resolved to its database and its permissions read; a data table that does not
|
||||
/// resolve reports the default alone.
|
||||
async fn list_datatable_roles(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
) -> Result<HashMap<String, (Vec<String>, String)>> {
|
||||
let names = list_datatable_names(db, w_id).await?;
|
||||
let access = DatatableAccess::Authed(authed.to_authed_ref());
|
||||
let mut roles = HashMap::new();
|
||||
for name in names {
|
||||
let record = match windmill_common::workspaces::resolve_datatable_database_unchecked(
|
||||
db, w_id, &name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((_, _, key)) => windmill_common::workspaces::database_permissions_by_key(db, &key)
|
||||
.await?
|
||||
.filter(|r| r.permissions.enabled),
|
||||
Err(_) => None,
|
||||
};
|
||||
let info = match record {
|
||||
Some(record) => (
|
||||
crate::datatable_permissions::usable_roles(db, w_id, &record, &access).await?,
|
||||
record.permissions.default_role().to_string(),
|
||||
),
|
||||
None => (vec![], ADMIN_DATATABLE_ROLE.to_string()),
|
||||
};
|
||||
roles.insert(name, info);
|
||||
}
|
||||
Ok(roles)
|
||||
}
|
||||
|
||||
async fn get_datatable_table_schema(
|
||||
_authed: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(query): Query<GetDataTableSchemaQuery>,
|
||||
) -> JsonResult<DataTableTableSchema> {
|
||||
let columns = get_datatable_table_columns(
|
||||
&db,
|
||||
&authed,
|
||||
&w_id,
|
||||
&query.datatable_name,
|
||||
&query.schema_name,
|
||||
&query.table_name,
|
||||
query.role.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -2402,6 +2506,26 @@ async fn get_datatable_table_schema(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve a data table for an API caller as the role it defaults to — schema
|
||||
/// browsing, the database manager and copy-between-databases name none — so a
|
||||
/// permissioned data table is reachable from the UI only by a tenant of that
|
||||
/// role (and by workspace admins, who may use any of them).
|
||||
async fn get_datatable_resource_as_default_role(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
w_id,
|
||||
datatable_name,
|
||||
None,
|
||||
DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_datatable_names(db: &DB, w_id: &str) -> Result<Vec<String>> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -2418,9 +2542,15 @@ async fn list_datatable_names(db: &DB, w_id: &str) -> Result<Vec<String>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Result<SchemaMap> {
|
||||
async fn get_datatable_schema(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
) -> Result<SchemaMap> {
|
||||
// Get the datatable resource (connection credentials)
|
||||
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
|
||||
let db_resource =
|
||||
get_datatable_resource_as_default_role(db, authed, w_id, datatable_name).await?;
|
||||
|
||||
// Parse the resource as PgDatabase
|
||||
let pg_db: PgDatabase = serde_json::from_value(db_resource)
|
||||
@@ -2444,6 +2574,9 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
|
||||
FROM pg_namespace
|
||||
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
|
||||
AND nspname NOT LIKE 'pg_%'
|
||||
-- Only what this connection's role can reach: a schema it cannot
|
||||
-- enter would list no tables and read as an empty one.
|
||||
AND has_schema_privilege(oid, 'USAGE, CREATE')
|
||||
ORDER BY nspname
|
||||
"#,
|
||||
&[],
|
||||
@@ -2511,8 +2644,30 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu
|
||||
Ok(schema_map)
|
||||
}
|
||||
|
||||
async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Result<TableListMap> {
|
||||
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
|
||||
/// What exists in a data table, and what its role may add to it.
|
||||
struct DataTableContents {
|
||||
tables: TableListMap,
|
||||
creatable_schemas: Vec<String>,
|
||||
can_create_schema: bool,
|
||||
manageable_schemas: Vec<String>,
|
||||
manageable_tables: Vec<String>,
|
||||
}
|
||||
|
||||
async fn get_datatable_tables(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
role: Option<&str>,
|
||||
) -> Result<DataTableContents> {
|
||||
let db_resource = get_datatable_resource_from_db(
|
||||
db,
|
||||
w_id,
|
||||
datatable_name,
|
||||
role,
|
||||
DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
let pg_db: PgDatabase = serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
|
||||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||||
@@ -2526,10 +2681,15 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
|
||||
let schema_rows = client
|
||||
.query(
|
||||
r#"
|
||||
SELECT nspname::text AS schema_name
|
||||
SELECT nspname::text AS schema_name,
|
||||
has_schema_privilege(oid, 'CREATE') AS can_create,
|
||||
pg_has_role(nspowner, 'USAGE') AS can_manage
|
||||
FROM pg_namespace
|
||||
WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
|
||||
AND nspname NOT LIKE 'pg_%'
|
||||
-- Only what this connection's role can reach: a schema it cannot
|
||||
-- enter would list no tables and read as an empty one.
|
||||
AND has_schema_privilege(oid, 'USAGE, CREATE')
|
||||
ORDER BY nspname
|
||||
"#,
|
||||
&[],
|
||||
@@ -2540,15 +2700,37 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
|
||||
})?;
|
||||
|
||||
let mut table_map: TableListMap = HashMap::new();
|
||||
let mut creatable_schemas: Vec<String> = Vec::new();
|
||||
let mut manageable_schemas: Vec<String> = Vec::new();
|
||||
let schema_names: Vec<String> = schema_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let name: String = row.get(0);
|
||||
table_map.entry(name.clone()).or_default();
|
||||
if row.get::<_, bool>(1) {
|
||||
creatable_schemas.push(name.clone());
|
||||
}
|
||||
if row.get::<_, bool>(2) {
|
||||
manageable_schemas.push(name.clone());
|
||||
}
|
||||
name
|
||||
})
|
||||
.collect();
|
||||
|
||||
let can_create_schema: bool = client
|
||||
.query_one(
|
||||
"SELECT has_database_privilege(current_database(), 'CREATE')",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to read the database privileges: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?
|
||||
.get(0);
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
r#"
|
||||
@@ -2573,15 +2755,43 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu
|
||||
table_map.entry(table_schema).or_default().push(table_name);
|
||||
}
|
||||
|
||||
Ok(table_map)
|
||||
// Ownership of a table is its own: a schema you own can hold one you do not.
|
||||
let manageable_tables = client
|
||||
.query(
|
||||
"SELECT n.nspname || '.' || c.relname
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = ANY($1) AND c.relkind = ANY(ARRAY['r','p','v','m','f']::\"char\"[])
|
||||
AND pg_has_role(c.relowner, 'USAGE')",
|
||||
&[&schema_names],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to read table ownership: {}",
|
||||
pg_error_message(&e)
|
||||
))
|
||||
})?
|
||||
.into_iter()
|
||||
.map(|row| row.get::<_, String>(0))
|
||||
.collect();
|
||||
|
||||
Ok(DataTableContents {
|
||||
tables: table_map,
|
||||
creatable_schemas,
|
||||
can_create_schema,
|
||||
manageable_schemas,
|
||||
manageable_tables,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_datatable_table_columns(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
schema_name: &str,
|
||||
table_name: &str,
|
||||
role: Option<&str>,
|
||||
) -> Result<ColumnMap> {
|
||||
if is_system_pg_schema(schema_name) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
@@ -2590,7 +2800,16 @@ async fn get_datatable_table_columns(
|
||||
)));
|
||||
}
|
||||
|
||||
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
|
||||
// Columns are what the connected role may see, so a caller on a role reads
|
||||
// them as that role.
|
||||
let db_resource = get_datatable_resource_from_db(
|
||||
db,
|
||||
w_id,
|
||||
datatable_name,
|
||||
role,
|
||||
DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
let pg_db: PgDatabase = serde_json::from_value(db_resource)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
|
||||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||||
@@ -2811,9 +3030,48 @@ pub(crate) async fn resolve_pg_source_checked(
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
source: &str,
|
||||
) -> Result<PgDatabase> {
|
||||
resolve_pg_source_as(db, user_db, authed, w_id, source, false).await
|
||||
}
|
||||
|
||||
/// As [`resolve_pg_source_checked`], but a workspace admin reaches a data table
|
||||
/// as `admin` rather than as the role it defaults to.
|
||||
///
|
||||
/// For the paths that copy a whole database: dumping as a restricted default
|
||||
/// role silently leaves out every table that role cannot read, which is a
|
||||
/// truncated copy rather than an error. A non-admin still resolves as their own
|
||||
/// role — `admin` is not theirs to ask for — so this hands out nothing.
|
||||
pub(crate) async fn resolve_pg_source_for_copy(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
source: &str,
|
||||
) -> Result<PgDatabase> {
|
||||
resolve_pg_source_as(db, user_db, authed, w_id, source, authed.is_admin).await
|
||||
}
|
||||
|
||||
async fn resolve_pg_source_as(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
source: &str,
|
||||
as_admin: bool,
|
||||
) -> Result<PgDatabase> {
|
||||
let db_resource = if let Some(name) = source.strip_prefix("datatable://") {
|
||||
get_datatable_resource_from_db_unchecked(db, w_id, name).await?
|
||||
if as_admin {
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
Some(ADMIN_DATATABLE_ROLE),
|
||||
DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
get_datatable_resource_as_default_role(db, authed, w_id, name).await?
|
||||
}
|
||||
} else if let Some(path) = source.strip_prefix("$res:") {
|
||||
let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed(
|
||||
authed,
|
||||
@@ -2871,7 +3129,8 @@ pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Re
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Same, for the `datatable://<name>` / `$res:<path>` form the import endpoints take.
|
||||
/// Same as [`is_instance_datatable`], for the `datatable://<name>` / `$res:<path>` form the
|
||||
/// import endpoints take.
|
||||
async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result<bool> {
|
||||
match source.strip_prefix("datatable://") {
|
||||
Some(name) => is_instance_datatable(db, w_id, name).await,
|
||||
@@ -3198,6 +3457,9 @@ async fn create_pg_database(
|
||||
Json(req): Json<CreatePgDatabaseRequest>,
|
||||
) -> Result<String> {
|
||||
windmill_common::validate_dbname(&req.target_dbname)?;
|
||||
if let Some(name) = req.source.strip_prefix("datatable://") {
|
||||
crate::datatable_permissions::refuse_clone_of_governed_datatable(&db, &w_id, name).await?;
|
||||
}
|
||||
|
||||
// Non-superadmin: restrict dbname to wm_fork_ prefix
|
||||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
@@ -3290,11 +3552,18 @@ async fn import_pg_database(
|
||||
}
|
||||
|
||||
let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly;
|
||||
let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let source_pg = resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.source).await?;
|
||||
let mut target_pg =
|
||||
resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?;
|
||||
resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.target).await?;
|
||||
|
||||
if let Some(ref override_dbname) = req.target_dbname_override {
|
||||
// Only the fork clone flow overrides the target database name; a plain
|
||||
// database-to-database import is an admin moving data between databases
|
||||
// they already reach.
|
||||
if let Some(name) = req.source.strip_prefix("datatable://") {
|
||||
crate::datatable_permissions::refuse_clone_of_governed_datatable(&db, &w_id, name)
|
||||
.await?;
|
||||
}
|
||||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
if !override_dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(
|
||||
@@ -3484,14 +3753,14 @@ async fn edit_datatable_config(
|
||||
let is_superadmin = require_super_admin(&db, &authed).await.is_ok();
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let old_datatables: HashMap<String, DataTable> = serde_json::from_value(
|
||||
sqlx::query_scalar!(
|
||||
"SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1",
|
||||
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
@@ -3544,9 +3813,23 @@ async fn edit_datatable_config(
|
||||
Some(true)
|
||||
}
|
||||
};
|
||||
// `forked_from` is stamped by the fork clone and read by the fork's
|
||||
// database drop as "this entry has a database of its own": the form may
|
||||
// update the schema snapshot inside it, never add or remove the stamp.
|
||||
let old = old_datatables.get(lookup);
|
||||
dt.forked_from = match (
|
||||
old.and_then(|old| old.forked_from.as_ref()),
|
||||
dt.forked_from.take(),
|
||||
) {
|
||||
(None, _) => None,
|
||||
(Some(_), Some(new)) => Some(new),
|
||||
(Some(old), None) => Some(windmill_common::workspaces::DataTableForkedFrom {
|
||||
schema: old.schema.clone(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let args_for_audit = format!("{:?}", new_config.settings);
|
||||
let args_for_audit = serde_json::to_string(&new_config.settings).unwrap_or_default();
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -7448,6 +7731,10 @@ async fn apply_forked_datatable(
|
||||
fdt: &ForkedDatatableInfo,
|
||||
) -> Result<()> {
|
||||
windmill_common::validate_dbname(&fdt.new_dbname)?;
|
||||
// The clone endpoints refuse this too; this is the one a caller cannot go
|
||||
// around, since it is what wires the fork's config to the copied database.
|
||||
crate::datatable_permissions::refuse_clone_of_governed_datatable(db, parent_w_id, &fdt.name)
|
||||
.await?;
|
||||
if !fdt.new_dbname.starts_with("wm_fork_") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Forked datatable database name '{}' must start with 'wm_fork_'",
|
||||
@@ -7973,6 +8260,31 @@ async fn create_workspace_fork(
|
||||
apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?;
|
||||
}
|
||||
|
||||
// A copy that was not forked here loses any `forked_from` it inherited: the stamp
|
||||
// means "cloned into this workspace's own database" — it is what lets the fork's
|
||||
// deletion drop that database — and a copy of the parent's clone points where the
|
||||
// parent points.
|
||||
let forked_datatable_names: Vec<String> = nw
|
||||
.forked_datatables
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect();
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, '{datatables}', (
|
||||
SELECT COALESCE(jsonb_object_agg(
|
||||
key,
|
||||
CASE WHEN key = ANY($2) THEN value ELSE value - 'forked_from' END
|
||||
), '{}'::jsonb)
|
||||
FROM jsonb_each(datatable->'datatables')
|
||||
))
|
||||
WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'"#,
|
||||
&forked_id,
|
||||
&forked_datatable_names[..],
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// The settings clone copies the source's ducklake config verbatim — including a parent
|
||||
// fork's own `fork_behavior` stamps. Sharing is a per-fork-creation choice, never
|
||||
// inherited: reset any cloned stamps first, then apply this fork's requested list.
|
||||
@@ -8775,6 +9087,27 @@ async fn leave_workspace(
|
||||
) -> Result<String> {
|
||||
windmill_api_auth::forbid_job_token_account_destruction(&authed)?;
|
||||
let mut tx = db.begin().await?;
|
||||
// Leaving frees the username here, and a role that still names it would be
|
||||
// inherited by the next member to take it. Read from the row rather than
|
||||
// from `authed`, whose username is cached and survives a rename: the name
|
||||
// that is about to be free is the one the row holds.
|
||||
let left = sqlx::query_scalar!(
|
||||
"SELECT username FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for username in &left {
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace_unchecked(
|
||||
&w_id,
|
||||
&format!("u/{username}"),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
&w_id,
|
||||
|
||||
@@ -62,6 +62,11 @@ pub(crate) async fn change_workspace_id(
|
||||
// rename moves the chain from one to the other.
|
||||
crate::workspaces::lock_dev_pairing(&mut tx, &[&old_id, &rw.new_id]).await?;
|
||||
|
||||
// The permissions this workspace owns move to the new id below, so a save of them
|
||||
// holds still meanwhile. Pairing lock first, as `lock_datatable_permissions_unchecked`
|
||||
// states.
|
||||
windmill_common::workspaces::lock_datatable_permissions_unchecked(&mut tx, &old_id).await?;
|
||||
|
||||
check_w_id_conflict(&mut tx, &rw.new_id).await?;
|
||||
|
||||
info!(
|
||||
@@ -120,6 +125,16 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// The permissions of every database this workspace turned on follow it: their tenants
|
||||
// are its principals, which the rows below move too.
|
||||
sqlx::query!(
|
||||
"UPDATE datatable_database_permissions SET owner_workspace_id = $1 WHERE owner_workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// The managed git-sync webhooks deliver to /api/w/{old_id}/... — a URL the
|
||||
// renamed workspace no longer answers on (the old id is archived and the
|
||||
// receiver skips it). Strip the webhook fields from the new row so polling
|
||||
@@ -978,6 +993,35 @@ pub(crate) async fn delete_workspace(
|
||||
vec![]
|
||||
});
|
||||
|
||||
// Same shape, same reason: the logins behind the workspace's data tables live in
|
||||
// databases that go with it, so what to drop is resolved while the config is here
|
||||
// and the drop itself runs after the commit. Nothing is dropped here.
|
||||
// The first rows this transaction locks — see `lock_datatable_permissions_unchecked`.
|
||||
// Without it a permissions save that commits between this read and the rows'
|
||||
// deletion adds a login nothing then drops.
|
||||
let mut planned_role_drops = Vec::new();
|
||||
windmill_common::workspaces::lock_datatable_permissions_unchecked(&mut tx, &w_id).await?;
|
||||
let datatable_config = sqlx::query_scalar!(
|
||||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
for name in datatable_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.get("datatables"))
|
||||
.and_then(|d| d.as_object())
|
||||
.map(|d| d.keys().map(|k| k.to_string()).collect::<Vec<String>>())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(planned) =
|
||||
crate::datatable_permissions::plan_drop_of_datatable_roles(&db, &w_id, &name).await
|
||||
{
|
||||
planned_role_drops.push((name, planned));
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1241,6 +1285,20 @@ pub(crate) async fn delete_workspace(
|
||||
);
|
||||
}
|
||||
|
||||
// The workspace that owned these roles is gone. Their logins go with it; the
|
||||
// permissions rows stay, ownerless — every entry still reaching those databases
|
||||
// finds every role refused rather than the owning connection.
|
||||
for (name, planned) in planned_role_drops {
|
||||
let key = planned.2.clone();
|
||||
crate::datatable_permissions::run_planned_drop_keeping_record(
|
||||
&db, &w_id, &name, None, planned,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = crate::datatable_permissions::restart_triggers_reaching(&db, &key).await {
|
||||
tracing::error!("Could not restart the triggers replicating {key}: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = dev_lock_parent {
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&parent);
|
||||
}
|
||||
@@ -1297,6 +1355,48 @@ pub struct DropForkedDatatableDatabasesRequest {
|
||||
datatable_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// Take the generated logins of a data table's database away before that
|
||||
/// database goes: a login is cluster-wide, so it would outlive the database it
|
||||
/// was created in. The permissions row stays until the drop succeeds — with its
|
||||
/// logins gone every role is refused meanwhile — and is forgotten by the caller
|
||||
/// once the database is. Returns the key of that database's permissions when
|
||||
/// this workspace owns them and nothing stands in the way of forgetting them:
|
||||
/// a row with no logins to drop included, or the database's next namesake would
|
||||
/// find it governed by a workspace that is gone.
|
||||
async fn drop_datatable_roles_before_its_database(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
dt_name: &str,
|
||||
) -> Option<String> {
|
||||
let (_, _, key) =
|
||||
windmill_common::workspaces::resolve_datatable_database_unchecked(db, w_id, dt_name)
|
||||
.await
|
||||
.ok()?;
|
||||
let record = windmill_common::workspaces::database_permissions_by_key(db, &key)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
if record.owner_workspace_id.as_deref() != Some(w_id) {
|
||||
return None;
|
||||
}
|
||||
if let Some(planned) =
|
||||
crate::datatable_permissions::plan_drop_of_datatable_roles(db, w_id, dt_name).await
|
||||
{
|
||||
if !crate::datatable_permissions::run_planned_drop_keeping_record(
|
||||
db,
|
||||
w_id,
|
||||
dt_name,
|
||||
Some(w_id),
|
||||
planned,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Drop forked datatable databases. Returns errors per datatable that failed.
|
||||
/// Same permission as delete_workspace: fork owner or super admin.
|
||||
pub async fn drop_forked_datatable_databases(
|
||||
@@ -1359,11 +1459,18 @@ pub async fn drop_forked_datatable_databases(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
|
||||
errors.push(format!(
|
||||
let permissions_key =
|
||||
drop_datatable_roles_before_its_database(&db, &w_id, dt_name).await;
|
||||
match windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
|
||||
Ok(()) => {
|
||||
if let Some(key) = permissions_key {
|
||||
crate::datatable_permissions::forget_database_permissions(&db, &key).await;
|
||||
}
|
||||
}
|
||||
Err(e) => errors.push(format!(
|
||||
"Could not drop instance database '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
));
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
let fork_pg = match crate::workspaces::resolve_pg_source_checked(
|
||||
@@ -1424,14 +1531,24 @@ pub async fn drop_forked_datatable_databases(
|
||||
match parent_pg.connect(Some(&db)).await {
|
||||
Ok((client, connection)) => {
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
if let Err(e) = client
|
||||
let permissions_key =
|
||||
drop_datatable_roles_before_its_database(&db, &w_id, dt_name).await;
|
||||
match client
|
||||
.execute(&format!("DROP DATABASE \"{}\"", db_to_drop), &[])
|
||||
.await
|
||||
{
|
||||
errors.push(format!(
|
||||
Ok(_) => {
|
||||
if let Some(key) = permissions_key {
|
||||
crate::datatable_permissions::forget_database_permissions(
|
||||
&db, &key,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => errors.push(format!(
|
||||
"Could not drop database '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
));
|
||||
)),
|
||||
}
|
||||
drop(client);
|
||||
let _ = windmill_common::shutdown_pg_connection(join_handle).await;
|
||||
@@ -1996,8 +2113,11 @@ async fn is_workspace_owner(
|
||||
/// `parent_workspace_id`, but a `wm-fork-` workspace can outlive its parent (the FK is
|
||||
/// `ON DELETE SET NULL`), so also treat the prefix as fork-ness — otherwise an orphaned fork would
|
||||
/// lose owner-self-delete. Used to gate owner-self-delete, which is permitted for forks/dev
|
||||
/// workspaces but requires superadmin otherwise.
|
||||
async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
|
||||
/// workspaces but requires superadmin otherwise, and to refuse a data table opt-in from a fork.
|
||||
///
|
||||
/// Authorization: performs none. It reads the lineage of any `w_id` it is handed, so callers
|
||||
/// MUST already have authorized the caller for that workspace.
|
||||
pub(crate) async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
|
||||
if w_id.starts_with(WM_FORK_PREFIX) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -4938,6 +4938,18 @@ paths:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: role_for
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: the data table the `role` below was picked on
|
||||
- name: role
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: read that data table's privileges as this role rather than its default
|
||||
responses:
|
||||
"200":
|
||||
description: table metadata of all datatables
|
||||
@@ -4971,6 +4983,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
required: false
|
||||
description: read the columns as this role rather than the data table's default one
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schema of one datatable table
|
||||
@@ -5186,6 +5204,260 @@ paths:
|
||||
error:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}:
|
||||
get:
|
||||
summary: get a datatable's role-based permissions (admins only)
|
||||
operationId: getDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: datatable permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DatatablePermissions"
|
||||
post:
|
||||
summary: set a datatable's role-based permissions, running the corresponding role SQL (admins only)
|
||||
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:
|
||||
$ref: "#/components/schemas/SetDatatablePermissions"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
|
||||
get:
|
||||
summary: list the datatable roles the caller may run 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: [enabled, roles, default_role]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}/preview:
|
||||
post:
|
||||
summary: preview the SQL a datatable permissions change would run (admins only)
|
||||
operationId: previewDatatablePermissions
|
||||
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/SetDatatablePermissions"
|
||||
responses:
|
||||
"200":
|
||||
description: statements that would be run, in a single transaction
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [statements, warnings]
|
||||
properties:
|
||||
statements:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions_import:
|
||||
post:
|
||||
summary: restore exported datatable permissions on the databases the named data tables reach and nobody governs yet (admins only); passwords are not restored, so every role is refused until saved again
|
||||
operationId: importDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [datatable, permissions]
|
||||
properties:
|
||||
datatable:
|
||||
description: a data table of this workspace reaching the database
|
||||
type: string
|
||||
permissions:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: the data tables whose database was already governed and left as it was
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}:
|
||||
get:
|
||||
summary: read the owner and grants of a datatable 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
|
||||
- name: role
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: read as this role rather than the caller's default one
|
||||
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 (a non-admin may act on what their role owns)
|
||||
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 be run, in a single transaction
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [statements, warnings]
|
||||
properties:
|
||||
statements:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply:
|
||||
post:
|
||||
summary: run an ownership or grant change (a non-admin may act on what their role owns)
|
||||
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/enable_datatable_migrations/{datatable_name}:
|
||||
post:
|
||||
summary: opt a datatable in to migrations (admins / super admins only)
|
||||
@@ -33965,6 +34237,63 @@ components:
|
||||
- ran
|
||||
- not_run
|
||||
- unknown
|
||||
DatatableRoleInfo:
|
||||
type: object
|
||||
required: [name, tenants]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
tenants:
|
||||
description: who may run as this role, as '*' (everyone), u/<user>, g/<group> or f/<folder>
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pg_rolename:
|
||||
description: the underlying postgres role, absent for admin
|
||||
type: string
|
||||
DatatablePermissions:
|
||||
type: object
|
||||
required: [enabled, roles, default_role, editable]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DatatableRoleInfo"
|
||||
default_role:
|
||||
description: the role a script gets when it names none
|
||||
type: string
|
||||
owner_workspace_id:
|
||||
description: the workspace that turned the permissions on, whose admins manage them and whose principals the tenants are; absent while they are off
|
||||
type: string
|
||||
editable:
|
||||
description: whether the caller may change them from this workspace (an admin of the owning workspace, or a superadmin)
|
||||
type: boolean
|
||||
SetDatatablePermissions:
|
||||
type: object
|
||||
required: [enabled, roles]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DatatableRoleInfo"
|
||||
default_role:
|
||||
description: the role a script gets when it names none; absent means admin
|
||||
type: string
|
||||
renames:
|
||||
description: role renames (old -> new), so a rename keeps the postgres role and its grants
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [from, to]
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
to:
|
||||
type: string
|
||||
DataTableSchema:
|
||||
type: object
|
||||
required: [datatable_name, schemas]
|
||||
@@ -33986,9 +34315,128 @@ components:
|
||||
error:
|
||||
type: string
|
||||
|
||||
AclTarget:
|
||||
type: object
|
||||
required: [kind]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database, schema, table]
|
||||
schema:
|
||||
type: string
|
||||
description: required for a schema or table target
|
||||
table:
|
||||
type: string
|
||||
|
||||
AclChange:
|
||||
type: object
|
||||
required: [type]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [set_owner, grant, revoke]
|
||||
role:
|
||||
type: string
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
scope:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
target,
|
||||
all_tables,
|
||||
all_sequences,
|
||||
all_functions,
|
||||
future_tables,
|
||||
future_sequences,
|
||||
future_functions
|
||||
]
|
||||
objects:
|
||||
type: array
|
||||
description: objects inside the target a revoke covers, empty for the target itself
|
||||
items:
|
||||
$ref: "#/components/schemas/AclObject"
|
||||
|
||||
AclChangeRequest:
|
||||
type: object
|
||||
required: [target, change]
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/AclTarget"
|
||||
change:
|
||||
$ref: "#/components/schemas/AclChange"
|
||||
role:
|
||||
type: string
|
||||
description: act as this role rather than the caller's default one
|
||||
|
||||
AclObject:
|
||||
type: object
|
||||
required: [name, kind]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
description: TABLE, SEQUENCE, ... — the keyword a REVOKE on it takes
|
||||
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]
|
||||
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
|
||||
|
||||
DatatableAclInfo:
|
||||
type: object
|
||||
required:
|
||||
[owner, roles, usable_roles, grants, supports_maintain, dbname, can_manage, current_role]
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
description: every role of the data table; the list is not private, only what each may reach
|
||||
items:
|
||||
type: string
|
||||
usable_roles:
|
||||
type: array
|
||||
description: the roles the caller may themselves run as
|
||||
items:
|
||||
type: string
|
||||
can_manage:
|
||||
type: boolean
|
||||
description: whether the caller's role may change this target at all
|
||||
current_role:
|
||||
type: string
|
||||
description: the role the caller is connected as
|
||||
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"
|
||||
|
||||
DataTableTables:
|
||||
type: object
|
||||
required: [datatable_name, schemas]
|
||||
required: [datatable_name, schemas, default_role]
|
||||
properties:
|
||||
datatable_name:
|
||||
type: string
|
||||
@@ -34001,6 +34449,32 @@ components:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
creatable_schemas:
|
||||
type: array
|
||||
description: the schemas the caller's role may create tables in
|
||||
items:
|
||||
type: string
|
||||
can_create_schema:
|
||||
type: boolean
|
||||
description: whether that role may create schemas in the database
|
||||
manageable_schemas:
|
||||
type: array
|
||||
description: the schemas whose access that role may change
|
||||
items:
|
||||
type: string
|
||||
manageable_tables:
|
||||
type: array
|
||||
description: the tables whose access that role may change, as schema.table
|
||||
items:
|
||||
type: string
|
||||
usable_roles:
|
||||
type: array
|
||||
description: roles the caller may run this data table as, empty when it has no permissions
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
description: the role the caller gets without naming one
|
||||
|
||||
DataTableTableSchema:
|
||||
type: object
|
||||
|
||||
@@ -518,8 +518,10 @@ pub(crate) async fn offboard_global_user(
|
||||
require_super_admin(&db, &authed).await?;
|
||||
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
|
||||
|
||||
// Ordered, because the offboarding below takes each workspace's settings row:
|
||||
// every path that takes more than one takes them in this sequence.
|
||||
let workspaces = sqlx::query!(
|
||||
"SELECT workspace_id, username FROM usr WHERE email = $1",
|
||||
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
|
||||
&email
|
||||
)
|
||||
.fetch_all(&db)
|
||||
@@ -830,6 +832,18 @@ async fn offboard_user_from_workspace<'c>(
|
||||
reassign_to: &str,
|
||||
new_permissioned_as: &str,
|
||||
) -> Result<OffboardSummary> {
|
||||
// Before this transaction locks anything else — see
|
||||
// `lock_datatable_permissions_unchecked`. Everything below reassigns rows a
|
||||
// rename or a deletion writes while holding those.
|
||||
windmill_common::workspaces::lock_datatable_permissions_unchecked(tx, w_id).await?;
|
||||
let datatable_settings = sqlx::query_scalar!(
|
||||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let new_prefix = reassign_to.to_string();
|
||||
let departing = windmill_common::users::username_to_permissioned_as(username);
|
||||
|
||||
@@ -986,6 +1000,25 @@ async fn offboard_user_from_workspace<'c>(
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
// A data table names its database by resource path, so one just moved has to
|
||||
// move in the config too: left behind it stops resolving, and the path it
|
||||
// named is free for a resource pointing somewhere else entirely.
|
||||
if let Some(mut settings) = datatable_settings {
|
||||
if windmill_common::workspaces::move_datatable_resource_paths(
|
||||
&mut settings,
|
||||
&format!("u/{username}/"),
|
||||
&format!("{new_prefix}/"),
|
||||
) {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||||
settings,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- eval datasets ----
|
||||
// The foreign keys cascade the rename onto cases and experiments; the paths held inside JSONB
|
||||
// (an experiment's subject, a dataset's scorers) are rewritten separately since the cascade
|
||||
|
||||
@@ -261,16 +261,10 @@ async fn rename_user(
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE password SET username = $1 WHERE email = $2",
|
||||
ru.new_username,
|
||||
user_email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Ordered, so a rename and a deletion that touch the same workspaces take
|
||||
// their settings rows in the same sequence rather than head-on.
|
||||
let workspace_usernames = sqlx::query!(
|
||||
"SELECT workspace_id, username FROM usr WHERE email = $1",
|
||||
"SELECT workspace_id, username FROM usr WHERE email = $1 ORDER BY workspace_id",
|
||||
&user_email
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
@@ -291,6 +285,16 @@ async fn rename_user(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// After the settings rows above: the deletion paths take `password` while
|
||||
// holding one, so taking it first here would be the other order.
|
||||
sqlx::query!(
|
||||
"UPDATE password SET username = $1 WHERE email = $2",
|
||||
ru.new_username,
|
||||
user_email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -316,19 +320,49 @@ async fn update_username_in_workpsace<'c>(
|
||||
new_username: &str,
|
||||
w_id: &str,
|
||||
) -> error::Result<()> {
|
||||
// Before anything else in the transaction — see
|
||||
// `lock_datatable_permissions_unchecked`. `rename_user` walks memberships in
|
||||
// `workspace_id` order, so a rename spanning workspaces takes their rows in
|
||||
// that order too.
|
||||
windmill_common::workspaces::lock_datatable_permissions_unchecked(tx, w_id).await?;
|
||||
let datatable_settings = sqlx::query_scalar!(
|
||||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
// ---- instance and workspace users ----
|
||||
|
||||
// Scoped to this workspace, like everything else here: `usr` and
|
||||
// `usr_to_group` rows of another workspace answer to that workspace's own
|
||||
// settings row.
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET username = $1 WHERE email = $2",
|
||||
"UPDATE usr SET username = $1 WHERE email = $2 AND workspace_id = $3",
|
||||
new_username,
|
||||
email
|
||||
email,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE usr_to_group SET usr = $1 WHERE usr = $2",
|
||||
"UPDATE usr_to_group SET usr = $1 WHERE usr = $2 AND workspace_id = $3",
|
||||
new_username,
|
||||
old_username
|
||||
old_username,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// ---- group_ ----
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE group_ SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
|
||||
new_username,
|
||||
old_username,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
@@ -813,17 +847,6 @@ async fn update_username_in_workpsace<'c>(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// ---- group_ ----
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE group_ SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
|
||||
new_username,
|
||||
old_username,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// ---- folders ----
|
||||
|
||||
sqlx::query!(
|
||||
@@ -921,6 +944,38 @@ async fn update_username_in_workpsace<'c>(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// ---- data table role tenants and backing resources ----
|
||||
|
||||
// Who may run as a data table role is stored as `u/<username>`, and the
|
||||
// executor compares it against the caller's name. Left behind, the rename
|
||||
// takes the role away from the user it followed and hands it to whoever
|
||||
// takes the old name next.
|
||||
windmill_common::workspaces::rename_datatable_tenant_in_workspace_unchecked(
|
||||
w_id,
|
||||
&format!("u/{old_username}"),
|
||||
&format!("u/{new_username}"),
|
||||
tx,
|
||||
)
|
||||
.await?;
|
||||
if let Some(mut settings) = datatable_settings {
|
||||
// The resource rewrite above moves a data table's own postgres resource
|
||||
// with everything else the user owns; the config names it by path, so it
|
||||
// has to travel too.
|
||||
if windmill_common::workspaces::move_datatable_resource_paths(
|
||||
&mut settings,
|
||||
&format!("u/{old_username}/"),
|
||||
&format!("u/{new_username}/"),
|
||||
) {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||||
settings,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1759,6 +1759,58 @@ pub(crate) async fn tarball_workspace(
|
||||
}
|
||||
}
|
||||
|
||||
// The permissions of the databases this workspace governs, as the import
|
||||
// endpoint takes them back: named by a data table of this workspace reaching
|
||||
// the database, with roles and tenants — no passwords, which are direct
|
||||
// database logins, and no login names, which a re-save generates. A database
|
||||
// no entry of the workspace reaches any more cannot be named, and is left out.
|
||||
// Admin-only, like the settings and like the drawer that shows the same thing.
|
||||
let permissions = if include_settings.unwrap_or(false) {
|
||||
windmill_common::workspaces::database_permissions_owned_by(&mut *tx, &w_id).await?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
if !permissions.is_empty() {
|
||||
let mut reaching: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
let datatables: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT jsonb_object_keys(datatable->'datatables') FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
for name in datatables {
|
||||
if let Ok((_, _, key)) =
|
||||
windmill_common::workspaces::resolve_datatable_database_unchecked(&db, &w_id, &name)
|
||||
.await
|
||||
{
|
||||
reaching.entry(key).or_insert(name);
|
||||
}
|
||||
}
|
||||
let exported: Vec<serde_json::Value> = permissions
|
||||
.into_iter()
|
||||
.filter_map(|mut row| {
|
||||
let datatable = reaching.get(&row.database_key)?;
|
||||
for role in row.permissions.roles.values_mut() {
|
||||
role.pg_rolename = None;
|
||||
role.pg_password = None;
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"datatable": datatable,
|
||||
"permissions": row.permissions,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::to_string_pretty(&exported)
|
||||
.map_err(|e| Error::internal_err(format!("serializing permissions: {e}")))?;
|
||||
archive
|
||||
.write_to_archive(&json, "datatable_permissions.json")
|
||||
.await?;
|
||||
}
|
||||
|
||||
archive.finish().await?;
|
||||
|
||||
let file = tokio::fs::File::open(&file_path).await?;
|
||||
|
||||
@@ -1482,6 +1482,69 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `custom_instance_user` gets on an instance database.
|
||||
///
|
||||
/// `WITH GRANT OPTION` throughout: data table permissions have Windmill connect
|
||||
/// as this role and hand privileges to the Postgres roles it creates, and a
|
||||
/// privilege it cannot pass on is one those roles can never receive. The
|
||||
/// database and schema `public` are owned by the instance's own Postgres user,
|
||||
/// not by this one, so re-granting is the only way it can reach them.
|
||||
/// The grants naming schema `public` are conditional on it being there: dropping
|
||||
/// it is ordinary hardening, and a GRANT on a schema that is absent is an error
|
||||
/// that would take the whole repair — and every grant after it — down with it.
|
||||
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 those grants to an instance database that already exists.
|
||||
///
|
||||
/// One provisioned before the grant options were added holds the privileges
|
||||
/// without them, and only the instance's own Postgres user — the connection
|
||||
/// this runs on — can add them: a role cannot give itself a grant option it
|
||||
/// does not hold.
|
||||
///
|
||||
/// Authorization: performs none, and this connects as the instance database's
|
||||
/// owner to change privileges. Callers MUST have authorized the operation the
|
||||
/// grants are for, and MUST pass a `dbname` they resolved from a data table's
|
||||
/// own config rather than one a request named — every caller today is a data
|
||||
/// table path that read it from `workspace_settings`.
|
||||
pub async fn ensure_instance_db_grant_options_unchecked(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
validate_dbname(dbname)?;
|
||||
|
||||
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
let creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds };
|
||||
let (client, connection) = creds.connect(Some(db)).await?;
|
||||
let join_handle = tokio::spawn(async move { connection.await });
|
||||
|
||||
let result = client.batch_execute(&instance_db_grants(dbname)).await;
|
||||
|
||||
drop(client);
|
||||
shutdown_pg_connection(join_handle).await?;
|
||||
|
||||
result.map_err(|e| {
|
||||
error::Error::internal_err(format!(
|
||||
"Failed to grant permissions on '{}': {}",
|
||||
dbname,
|
||||
crate::error::pg_error_message(&e)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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").
|
||||
pub async fn create_custom_instance_database(
|
||||
@@ -1521,17 +1584,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,
|
||||
|
||||
@@ -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,17 @@ 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))?;
|
||||
let query = format!(
|
||||
"ALTER SCHEMA {} RENAME TO {};",
|
||||
qi(&p.schema, db_type),
|
||||
qi(&p.new_schema, db_type)
|
||||
);
|
||||
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
||||
}
|
||||
|
||||
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 +2617,10 @@ 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, so 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, CREATE')".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(),
|
||||
@@ -4084,9 +4106,16 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CREATE SCHEMA / DROP SCHEMA
|
||||
// CREATE SCHEMA / DROP SCHEMA / RENAME SCHEMA
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[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() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"new_schema"}"#;
|
||||
@@ -4468,6 +4497,9 @@ mod tests {
|
||||
assert!(sql.contains("schema_name"));
|
||||
assert!(sql.contains("table_name"));
|
||||
assert!(sql.contains("c.relkind = 'r'"));
|
||||
// Listing every table must not reach into schemas the connection's role
|
||||
// cannot enter: pg_catalog itself is readable by everyone.
|
||||
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE, CREATE')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1081,6 +1081,41 @@ pub struct SqlAnnotations {
|
||||
pub raw_output: bool,
|
||||
}
|
||||
|
||||
impl SqlAnnotations {
|
||||
/// If the script declares `-- role <name>`, returns the data table role the
|
||||
/// query runs as. Only meaningful against a permissioned `datatable://`
|
||||
/// database; absent means the data table's default role.
|
||||
///
|
||||
/// Mirrors `BashAnnotations::ssh_target`: only leading comment lines are
|
||||
/// scanned, and an exact `-- role <name>` with a valid role name and nothing
|
||||
/// else on the line is required, so prose like `-- role based access is
|
||||
/// handled below` never matches.
|
||||
pub fn datatable_role(code: &str) -> Option<String> {
|
||||
for line in code.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !line.starts_with("--") {
|
||||
break;
|
||||
}
|
||||
let mut tokens = line[2..].split_whitespace();
|
||||
if tokens.next() == Some("role") {
|
||||
if let Some(role) = tokens.next() {
|
||||
let is_role_name = !role.is_empty()
|
||||
&& role
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
|
||||
if is_role_name && tokens.next().is_none() {
|
||||
return Some(role.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[annotations("#")]
|
||||
pub struct BashAnnotations {
|
||||
pub docker: bool,
|
||||
@@ -2607,6 +2642,35 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn datatable_role_annotation_is_read_from_the_leading_comment_block() {
|
||||
assert_eq!(
|
||||
SqlAnnotations::datatable_role("-- role analyst\nSELECT 1"),
|
||||
Some("analyst".to_string())
|
||||
);
|
||||
// Argument declarations are comments too, so the annotation still parses
|
||||
// after them.
|
||||
assert_eq!(
|
||||
SqlAnnotations::datatable_role("-- $1 name (text)\n-- role read-only_1\nSELECT 1"),
|
||||
Some("read-only_1".to_string())
|
||||
);
|
||||
assert_eq!(SqlAnnotations::datatable_role("SELECT 1"), None);
|
||||
// Scanning stops at the first non-comment line, so a `-- role` further
|
||||
// down is prose about the query, not a directive.
|
||||
assert_eq!(
|
||||
SqlAnnotations::datatable_role("SELECT 1;\n-- role analyst"),
|
||||
None
|
||||
);
|
||||
for prose in [
|
||||
"-- role based access is handled below\nSELECT 1",
|
||||
"-- role\nSELECT 1",
|
||||
"-- roles analyst\nSELECT 1",
|
||||
"-- role bad;name\nSELECT 1",
|
||||
] {
|
||||
assert_eq!(SqlAnnotations::datatable_role(prose), None, "{prose}");
|
||||
}
|
||||
}
|
||||
|
||||
fn matcher(id: &str) -> WorkspaceMatcher {
|
||||
WorkspaceMatcher { id: id.to_string(), include_forks: false }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -134,7 +134,10 @@ pub struct EditResourceType {
|
||||
/// `Option` conflates: an absent field leaves the extension alone, while an
|
||||
/// explicit `null` clears it. A hub pull relies on both — a type that stops
|
||||
/// being a file type has to stop being one locally too.
|
||||
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "windmill_common::more_serde::double_option"
|
||||
)]
|
||||
pub format_extension: Option<Option<String>>,
|
||||
}
|
||||
|
||||
@@ -1626,7 +1629,6 @@ async fn delete_resources_bulk(
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture resources for trashbin per path before bulk delete, and
|
||||
@@ -2129,6 +2131,8 @@ async fn set_resource_value(
|
||||
}
|
||||
authorize_azure_devops_reference(authed, db, user_db, w_id, value.as_ref()).await?;
|
||||
|
||||
// A data table's roles live in the database its resource points at, so the
|
||||
// identity behind that path is not free to move while they exist.
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
|
||||
// `RETURNING resource_type` rather than a second lookup: the advisory below has to know the
|
||||
|
||||
@@ -383,7 +383,15 @@ pub async fn resolve_postgres_resource(
|
||||
) -> Result<Postgres> {
|
||||
if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") {
|
||||
// Trigger connections (publication/slot management + logical replication) run
|
||||
// as the dedicated replication user on custom-instance databases.
|
||||
// as the dedicated replication user on custom-instance databases — and stream
|
||||
// every change of the database, so a permissioned one is for its admins.
|
||||
windmill_common::workspaces::ensure_datatable_admin_access(
|
||||
db,
|
||||
w_id,
|
||||
datatable_name,
|
||||
&windmill_common::workspaces::DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
let resource_value =
|
||||
get_datatable_replication_resource_from_db_unchecked(db, w_id, datatable_name).await?;
|
||||
serde_json::from_value::<Postgres>(resource_value).map_err(|e| Error::SerdeJson {
|
||||
|
||||
@@ -65,15 +65,23 @@ pub async fn get_ducklake_from_agent_http(
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// An agent worker authenticates as the agent rather than as the job's user, so
|
||||
/// the job id rides along: the API resolves its owner and authorizes the role
|
||||
/// against that identity.
|
||||
pub async fn get_datatable_resource_from_agent_http(
|
||||
client: &HttpClient,
|
||||
name: &str,
|
||||
w_id: &str,
|
||||
role: Option<&str>,
|
||||
job_id: &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, parse_datatable_ref,
|
||||
strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType,
|
||||
};
|
||||
use windmill_common::PgDatabase;
|
||||
use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE;
|
||||
@@ -1492,13 +1492,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 {
|
||||
@@ -1573,13 +1569,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 {
|
||||
@@ -2607,26 +2599,65 @@ fn fork_defer_statements(
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
/// What an `ATTACH 'datatable…' AS x` names: the data table, the role it asks
|
||||
/// for, and the alias the rest of the query uses.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct AttachedDatatable {
|
||||
name: String,
|
||||
role: Option<String>,
|
||||
alias: String,
|
||||
}
|
||||
|
||||
/// Split an ATTACH reference, which is the syntax users type.
|
||||
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 = format!(
|
||||
"{}{}",
|
||||
cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main"),
|
||||
cap.get(2).map(|m| m.as_str()).unwrap_or("")
|
||||
);
|
||||
let (name, role) = parse_datatable_ref(&reference);
|
||||
Some(AttachedDatatable {
|
||||
name: name.to_string(),
|
||||
role: role.map(|r| r.to_string()),
|
||||
alias: cap.get(3).map(|m| m.as_str()).unwrap_or("").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
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("");
|
||||
let name = attached.name.as_str();
|
||||
let role = attached.role.as_deref();
|
||||
let alias_name = attached.alias.as_str();
|
||||
let w_id = job.workspace_id.as_str();
|
||||
|
||||
let db_resource = match conn {
|
||||
Connection::Http(client) => {
|
||||
get_datatable_resource_from_agent_http(client, name, w_id).await?
|
||||
get_datatable_resource_from_agent_http(client, name, w_id, role, &job.id).await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
role,
|
||||
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()) {
|
||||
@@ -2751,6 +2782,31 @@ pub struct Arg {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attach_ref(query: &str) -> Option<(String, Option<String>, String)> {
|
||||
parse_attach_datatable(query).map(|a| (a.name, a.role, a.alias))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_datatable_parses_name_and_role() {
|
||||
assert_eq!(
|
||||
attach_ref("ATTACH 'datatable://sales?role=analyst' AS dt;"),
|
||||
Some(("sales".into(), Some("analyst".into()), "dt".into()))
|
||||
);
|
||||
// Implicit name.
|
||||
assert_eq!(
|
||||
attach_ref("ATTACH 'datatable?role=analyst' AS dt;"),
|
||||
Some(("main".into(), Some("analyst".into()), "dt".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
attach_ref("ATTACH 'datatable://sales' AS dt;"),
|
||||
Some(("sales".into(), None, "dt".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
attach_ref("ATTACH 'datatable' AS dt;"),
|
||||
Some(("main".into(), None, "dt".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ffi_error_unescapes_multiline_and_strips_quotes() {
|
||||
// Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ".
|
||||
|
||||
@@ -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, 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,37 @@ 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://");
|
||||
let (db_str, uri_role) = parse_datatable_ref(reference);
|
||||
// `-- role <name>` rather than an argument: an argument named
|
||||
// `role` would collide with a query parameter of that name. It
|
||||
// wins over a role carried by the reference, which is how
|
||||
// generated SQL (the database manager's) selects one.
|
||||
let role = SqlAnnotations::datatable_role(query)
|
||||
.or_else(|| uri_role.map(|r| r.to_string()));
|
||||
Some(match conn {
|
||||
Connection::Http(client) => {
|
||||
get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id)
|
||||
.await?
|
||||
get_datatable_resource_from_agent_http(
|
||||
client,
|
||||
&db_str,
|
||||
&job.workspace_id,
|
||||
role.as_deref(),
|
||||
&job.id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str)
|
||||
.await?
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&db_str,
|
||||
role.as_deref(),
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+22
-8
@@ -1106,6 +1106,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts - Optional settings; \`role\` runs the query as that data table
|
||||
* role (default: the data table's default role)
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1115,8 +1117,10 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* let sql = wmill.datatable('main', { role: 'operator' })
|
||||
*/
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -1893,6 +1897,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts - Optional settings; \`role\` runs the query as that data table
|
||||
* role (default: the data table's default role)
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1902,8 +1908,10 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* let sql = wmill.datatable('main', { role: 'operator' })
|
||||
*/
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -2774,6 +2782,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts - Optional settings; \`role\` runs the query as that data table
|
||||
* role (default: the data table's default role)
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -2783,8 +2793,10 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* let sql = wmill.datatable('main', { role: 'operator' })
|
||||
*/
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -4393,10 +4405,12 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
#
|
||||
# Args:
|
||||
# name: Database name (default: "main")
|
||||
# role: DataTable role to run as, on a datatable with permissions
|
||||
# enabled (default: the data table's default role)
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main')
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
@@ -4587,7 +4601,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
|
||||
#
|
||||
# @task(path="f/external_script", timeout=600, tag="gpu")
|
||||
# async def run_external(x: int): ...
|
||||
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)
|
||||
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)
|
||||
|
||||
# Create a task that dispatches to a separate Windmill script.
|
||||
#
|
||||
@@ -4598,7 +4612,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)
|
||||
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)
|
||||
|
||||
# Create a task that dispatches to a separate Windmill flow.
|
||||
#
|
||||
@@ -4609,7 +4623,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)
|
||||
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)
|
||||
|
||||
# Decorator marking an async function as a workflow-as-code entry point.
|
||||
#
|
||||
@@ -4670,7 +4684,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.
|
||||
#
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
|
||||
import type { DataTableTables } from '$lib/gen'
|
||||
import type { DatatableRowAction } from './dbTypes'
|
||||
import { sortArray } from '$lib/utils'
|
||||
import { Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
@@ -18,11 +20,10 @@
|
||||
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 { 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 +37,16 @@
|
||||
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 drawer is not on a data table, which is what collapses
|
||||
* the tree's top level away. */
|
||||
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 +69,13 @@
|
||||
hasReplResult = $bindable(false),
|
||||
selectedSchemaKey = $bindable(undefined),
|
||||
selectedTableKey = $bindable(undefined),
|
||||
dbSelector,
|
||||
datatableTree,
|
||||
datatableTreeLoading,
|
||||
onSelectDatatable,
|
||||
onSelectRole,
|
||||
pendingAction = $bindable(),
|
||||
onDatatableAction,
|
||||
canManageDatatable,
|
||||
multiSelectMode = false,
|
||||
selectedTables = $bindable([]),
|
||||
disabledTables = [],
|
||||
@@ -77,7 +93,10 @@
|
||||
function getDbSchemasPath(input: DbInput): string {
|
||||
switch (input.type) {
|
||||
case 'database':
|
||||
return input.resourcePath
|
||||
// The role is part of the identity: two roles on the same data table
|
||||
// may see different tables, so their schemas cannot share a cache
|
||||
// entry — and the same string is what selects the role downstream.
|
||||
return input.resourcePath + (input.role ? `?role=${input.role}` : '')
|
||||
case 'ducklake':
|
||||
return 'ducklake://' + input.ducklake
|
||||
}
|
||||
@@ -143,7 +162,7 @@
|
||||
let queryError: string | undefined
|
||||
const schema = await getDbSchemas(
|
||||
input.resourceType,
|
||||
input.resourcePath,
|
||||
getDbSchemasPath(input),
|
||||
ws,
|
||||
(message: string) => (queryError = message),
|
||||
{ customTag: workerTag }
|
||||
@@ -305,7 +324,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
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { WorkspaceService, type DataTableTables } from '$lib/gen'
|
||||
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, Expand, Minimize, RefreshCcw } 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 type { DatatableRowAction } from './dbTypes'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -41,24 +35,83 @@
|
||||
// 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
|
||||
}))
|
||||
// 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.
|
||||
const datatables = resource(
|
||||
// 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: this reaches every data table's database in turn, and the
|
||||
// component is mounted on every logged-in page.
|
||||
() => [open, ws, uriState.selectedDatatable, uriState.selectedRole] as const,
|
||||
async ([isOpen, workspace, roleFor, role]): Promise<DataTableTables[]> => {
|
||||
if (!isOpen || !workspace) return []
|
||||
try {
|
||||
return await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return []
|
||||
}
|
||||
},
|
||||
{ initialValue: [] }
|
||||
)
|
||||
|
||||
// Roles the *caller* may use, so the picker never offers one that would be
|
||||
// refused. Absent/disabled permissions yield no roles and hide the picker.
|
||||
const usableRoles = resource(
|
||||
() => [ws, uriState.selectedDatatable] as const,
|
||||
async ([workspace, datatable]) => {
|
||||
if (!workspace || !datatable) return undefined
|
||||
try {
|
||||
return {
|
||||
datatable,
|
||||
...(await WorkspaceService.listUsableDatatableRoles({
|
||||
workspace,
|
||||
datatableName: 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, enabled: false, roles: [], default_role: 'admin' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 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 === uriState.selectedDatatable ? usableRoles.current : undefined
|
||||
)
|
||||
|
||||
// The content must not mount until the role is settled: mounting is what fires
|
||||
// the schema and metadata queries, and 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.enabled ||
|
||||
rolesOfCurrent.roles.length === 0 ||
|
||||
uriState.selectedRole !== undefined))
|
||||
)
|
||||
|
||||
// Settle the role before anything queries the data table: the schema and
|
||||
// metadata fetches run as whatever role the input carries, so leaving it unset
|
||||
// until the user touches the picker would send the first — and cached — round
|
||||
// of queries as a role they may not be allowed to use.
|
||||
$effect(() => {
|
||||
const roles = rolesOfCurrent
|
||||
if (!roles?.enabled || uriState.selectedRole !== undefined) return
|
||||
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
|
||||
if (effective) untrack(() => (uriState.selectedRole = effective))
|
||||
})
|
||||
|
||||
// Refetch datatables when switching to a datatable input
|
||||
$effect(() => {
|
||||
if (uriState.isDatatableInput) {
|
||||
@@ -78,6 +131,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 +154,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 +175,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?.openPermissions()
|
||||
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 +232,7 @@
|
||||
|
||||
async function handleImportDatabase() {
|
||||
if (!importSource || !ws) return
|
||||
const target = currentSourceIdentifier()
|
||||
const target = importTarget ?? currentSourceIdentifier()
|
||||
if (!target) return
|
||||
importLoading = true
|
||||
try {
|
||||
@@ -183,12 +278,23 @@
|
||||
noPadding
|
||||
id="db-manager-drawer"
|
||||
>
|
||||
{#if uriState.effectiveInput && ws}
|
||||
{#key uriState.selectedDatatable}
|
||||
{#if uriState.effectiveInput && ws && roleSettled}
|
||||
{#key `${uriState.selectedDatatable}~${uriState.selectedRole ?? ''}`}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
input={uriState.effectiveInput}
|
||||
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)}
|
||||
onDatatableAction={runDatatableAction}
|
||||
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey={uriState.selectedSchema}
|
||||
@@ -196,42 +302,10 @@
|
||||
onImport={enableImportExport
|
||||
? (mode) => ((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)}>
|
||||
Import
|
||||
</Button>
|
||||
{/if}
|
||||
{#if uriState.effectiveInput && ws}
|
||||
<DbWorkerTagButton
|
||||
bind:tag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
@@ -260,6 +334,25 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#if actionDatatable && ws}
|
||||
{#key actionDatatable}
|
||||
<DataTableMigrationsButton
|
||||
bind:this={migrationsModal}
|
||||
hideTrigger
|
||||
workspace={ws}
|
||||
datatable={actionDatatable}
|
||||
onSchemaChanged={refreshManager}
|
||||
/>
|
||||
<DataTablePermissionsButton
|
||||
bind:this={permissionsDrawer}
|
||||
hideTrigger
|
||||
workspace={ws}
|
||||
datatable={actionDatatable}
|
||||
onSaved={refreshRoles}
|
||||
/>
|
||||
{/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>
|
||||
@@ -4,10 +4,22 @@
|
||||
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
|
||||
import { withMigrationRole } from './datatableMigrationRole'
|
||||
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
|
||||
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
role
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
/** The role the surrounding editor/manager is connected as. A migration
|
||||
* created here declares it, so the DDL runs with the privileges the user
|
||||
* is actually working under rather than the data table's default. */
|
||||
role?: string
|
||||
} = $props()
|
||||
|
||||
type Choice = 'run' | 'migrate' | 'cancel'
|
||||
|
||||
@@ -69,7 +81,7 @@
|
||||
function openMigrationModal(statement: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolveMigrationClosed = (created: boolean) => resolve(created)
|
||||
newMigrationModal?.open({ codeUp: statement })
|
||||
newMigrationModal?.open({ codeUp: withMigrationRole(statement, role) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +146,11 @@
|
||||
This looks like a schema-changing (DDL) statement. Schema changes are best tracked as
|
||||
migrations rather than run ad-hoc. Create a migration for it instead?
|
||||
</p>
|
||||
{#if role}
|
||||
<p class="text-sm text-secondary">
|
||||
It will run as role <span class="font-mono font-semibold">{role}</span>.
|
||||
</p>
|
||||
{/if}
|
||||
<pre
|
||||
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
|
||||
>{promptStatement ?? ''}</pre
|
||||
|
||||
@@ -216,5 +216,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 : 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))
|
||||
@@ -29,17 +30,15 @@
|
||||
onmouseleave={() => (buttonHover = false)}
|
||||
class="p-1"
|
||||
>
|
||||
<!-- Never filled: a favourite reads as yellow, not as a solid blob, which keeps
|
||||
the icon the same weight whether or not it is starred. -->
|
||||
{#if starred}
|
||||
{#if buttonHover}
|
||||
<StarOff size={16} fill="currentcolor" />
|
||||
<StarOff {size} fill="none" class="text-yellow-500" />
|
||||
{:else}
|
||||
<Star size={16} fill="currentcolor" />
|
||||
<Star {size} fill="none" class="text-yellow-500" />
|
||||
{/if}
|
||||
{:else}
|
||||
<Star
|
||||
class={!buttonHover ? 'opacity-60' : ''}
|
||||
size={16}
|
||||
fill={buttonHover ? 'currentcolor' : 'none'}
|
||||
/>
|
||||
<Star {size} fill="none" class={buttonHover ? '' : 'opacity-60'} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -280,9 +280,14 @@ const scriptsV2: typeof legacyScripts = {
|
||||
...legacyScripts,
|
||||
postgresql: {
|
||||
...legacyScripts.postgresql,
|
||||
// The right join is what keeps a schema with no table in the list. It reads
|
||||
// `pg_namespace`, which every role can read in full, so without the
|
||||
// privilege check a schema the connection cannot enter shows up too — as an
|
||||
// empty one, since `information_schema.columns` does filter by privilege.
|
||||
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 nsp.nspname NOT LIKE 'pg\\_%' AND has_schema_privilege(nsp.oid, 'USAGE, CREATE')`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -665,12 +665,14 @@ export class AIChatManager {
|
||||
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
|
||||
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
|
||||
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's queries run as. */
|
||||
datatableCreationPolicy = $state<{
|
||||
enabled: boolean
|
||||
datatable: string | undefined
|
||||
schema: string | undefined
|
||||
}>({ enabled: false, datatable: undefined, schema: undefined })
|
||||
role?: string | undefined
|
||||
}>({ enabled: false, datatable: undefined, schema: undefined, role: undefined })
|
||||
pendingNewCode = $state<string | undefined>(undefined)
|
||||
apiTools = $state<Tool<any>[]>([])
|
||||
aiChatInput = $state<AIChatInput | null>(null)
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
const aiChatManager = getAiChatManager()
|
||||
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
|
||||
import {
|
||||
createDatatablesResource,
|
||||
roleAfterDatatableChange
|
||||
} from '$lib/components/raw_apps/datatableUtils.svelte'
|
||||
|
||||
// Load available datatables from workspace using shared utility
|
||||
const datatables = createDatatablesResource(() => $workspaceStore)
|
||||
@@ -37,8 +40,10 @@
|
||||
}
|
||||
|
||||
function handleDefaultChange(datatable: string | undefined, schema: string | undefined) {
|
||||
aiChatManager.datatableCreationPolicy.datatable = datatable
|
||||
aiChatManager.datatableCreationPolicy.schema = schema
|
||||
const policy = aiChatManager.datatableCreationPolicy
|
||||
policy.role = roleAfterDatatableChange(policy.datatable, datatable, policy.role)
|
||||
policy.datatable = datatable
|
||||
policy.schema = schema
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,6 +72,7 @@
|
||||
<DefaultDatabaseSelector
|
||||
datatable={aiChatManager.datatableCreationPolicy.datatable}
|
||||
schema={aiChatManager.datatableCreationPolicy.schema}
|
||||
role={aiChatManager.datatableCreationPolicy.role}
|
||||
onChange={handleDefaultChange}
|
||||
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
|
||||
/>
|
||||
|
||||
@@ -921,9 +921,14 @@ 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}')`
|
||||
// `wmill.datatable()` for 'main' without a role — the defaults — and the name
|
||||
// and role spelled out otherwise. A role names the privileges the app's own
|
||||
// queries run with, so it has to be in the code the model writes.
|
||||
const datatableCall = policy.role
|
||||
? `wmill.datatable('${datatableName}', { role: '${policy.role}' })`
|
||||
: datatableName === 'main'
|
||||
? 'wmill.datatable()'
|
||||
: `wmill.datatable('${datatableName}')`
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService, type AclChange, type AclTarget, type DatatableAclInfo } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Portal from '../Portal.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 { Trash2 } from 'lucide-svelte'
|
||||
import PgGrantBuilder from './PgGrantBuilder.svelte'
|
||||
import { grantScopeLabel, groupGrants, revokeScopeOf } from './aclScopes'
|
||||
import { ADMIN_DATATABLE_ROLE } from '../dbTypes'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
target,
|
||||
role,
|
||||
showOwner = true
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
/** Read and act as this role rather than the caller's default one. */
|
||||
role?: string
|
||||
/** Off where ownership is not the caller's to move — a data table's whole
|
||||
* database, which Windmill owns. */
|
||||
showOwner?: boolean
|
||||
/** What owner and grants are read and written for. Schemas today; a table
|
||||
* target is the same call with one more identifier. */
|
||||
target: AclTarget
|
||||
} = $props()
|
||||
|
||||
const acl = resource(
|
||||
() => [workspace, datatable, JSON.stringify(target), role] as const,
|
||||
async ([ws, dt]) =>
|
||||
await WorkspaceService.getDatatableAcl({
|
||||
workspace: ws,
|
||||
datatableName: dt,
|
||||
kind: target.kind,
|
||||
schema: target.schema,
|
||||
table: target.kind === 'table' ? target.table : undefined,
|
||||
role
|
||||
})
|
||||
)
|
||||
|
||||
// Nothing is written before its SQL has been shown: creating and especially
|
||||
// revoking access is not something to discover afterwards.
|
||||
let pending = $state<
|
||||
{ change: AclChange; statements: string[]; warnings: string[]; title: string } | undefined
|
||||
>(undefined)
|
||||
|
||||
/** A revoke listed per object takes them all: say where one of them is
|
||||
* managed on its own. */
|
||||
const pendingCoversObjects = $derived((pending?.change.objects?.length ?? 0) > 1)
|
||||
|
||||
async function confirm(change: AclChange, title: string) {
|
||||
planning = true
|
||||
try {
|
||||
const plan = await WorkspaceService.planDatatableAcl({
|
||||
workspace,
|
||||
datatableName: datatable,
|
||||
requestBody: { target, change, role }
|
||||
})
|
||||
pending = { change, statements: plan.statements, warnings: plan.warnings, title }
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(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, role }
|
||||
})
|
||||
sendUserToast(pending.title)
|
||||
pending = undefined
|
||||
await acl.refetch()
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
applying = false
|
||||
}
|
||||
}
|
||||
|
||||
const info: DatatableAclInfo | undefined = $derived(acl.current)
|
||||
const roleItems = $derived((info?.roles ?? []).map((r) => ({ value: r, label: r })))
|
||||
const grantRows = $derived(groupGrants(info?.grants ?? []))
|
||||
|
||||
/** Handing it to a role the caller cannot run as: after this they can no
|
||||
* longer change it back. */
|
||||
const pendingGivesItAway = $derived(
|
||||
pending?.change.type === 'set_owner' &&
|
||||
!!pending.change.role &&
|
||||
!(info?.usable_roles ?? []).includes(pending.change.role)
|
||||
)
|
||||
let planning = $state(false)
|
||||
let applying = $state(false)
|
||||
</script>
|
||||
|
||||
{#if acl.error}
|
||||
<Alert type="error" title="Could not read access" size="xs">{String(acl.error)}</Alert>
|
||||
{:else if !info}
|
||||
<span class="text-sm text-tertiary">Loading...</span>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if showOwner}
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-semibold text-primary">Owner</span>
|
||||
<span class="text-xs text-secondary">
|
||||
{target.kind === 'schema'
|
||||
? 'The role that owns the schema and everything already in it. Changing it also lets the new owner reach what the other roles create here later.'
|
||||
: 'The role that owns the table. Its owner may always read and write it, and is who ALTER and DROP answer to.'}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
items={roleItems}
|
||||
clearable={false}
|
||||
disabled={planning || applying}
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
{#if !info.roles.includes(info.owner)}
|
||||
<span class="text-xs text-tertiary">
|
||||
Currently owned by <span class="font-mono">{info.owner}</span>, which is not one of this
|
||||
data table's roles.
|
||||
</span>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-semibold text-primary">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.'
|
||||
: 'What each role may do, beyond what it owns.'}
|
||||
</span>
|
||||
</div>
|
||||
<PgGrantBuilder
|
||||
{target}
|
||||
roles={info.roles}
|
||||
disabled={planning || applying || !info.can_manage}
|
||||
supportsMaintain={info.supports_maintain}
|
||||
dbname={info.dbname}
|
||||
onAdd={({ role, privileges, scope }) =>
|
||||
confirm(
|
||||
{ type: 'grant', role, privileges, scope },
|
||||
`Granted ${privileges.join(', ')} to ${role}`
|
||||
)}
|
||||
/>
|
||||
{#if grantRows.length === 0}
|
||||
<span class="text-xs text-tertiary">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 (grant.grantee + grant.objects
|
||||
.map((o) => `${o.name}(${o.args ?? ''})`)
|
||||
.join() + (grant.future ?? ''))}
|
||||
<Row>
|
||||
<Cell first>{grant.grantee}</Cell>
|
||||
<Cell><span class="font-mono text-2xs">{grant.privileges.join(', ')}</span></Cell>
|
||||
<Cell>{grantScopeLabel(grant)}</Cell>
|
||||
<Cell last>
|
||||
{@const revokeScope = revokeScopeOf(grant)}
|
||||
<!-- Reading a target's access needs no ownership of it, so a row
|
||||
on one the caller does not own has nothing to offer. And what
|
||||
`admin` holds is what every role here connects through, so it
|
||||
is not the drawer's to take away wherever it appears. -->
|
||||
{#if info.roles.includes(grant.grantee) && revokeScope && info.can_manage && grant.grantee !== ADMIN_DATATABLE_ROLE}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Revoke"
|
||||
disabled={planning || applying}
|
||||
on:click={() =>
|
||||
confirm(
|
||||
{
|
||||
type: 'revoke',
|
||||
role: grant.grantee,
|
||||
privileges: grant.privileges,
|
||||
scope: revokeScope,
|
||||
objects: grant.objects
|
||||
},
|
||||
`Revoked ${grant.privileges.join(', ')} from ${grant.grantee}`
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Portal>
|
||||
<ConfirmationModal
|
||||
open={!!pending}
|
||||
title="Confirm running the following"
|
||||
confirmationText="Run"
|
||||
type="info"
|
||||
loading={applying}
|
||||
onConfirmed={apply}
|
||||
onCanceled={() => (pending = undefined)}
|
||||
>
|
||||
<div class="flex flex-col gap-3 min-w-0">
|
||||
{#if pendingGivesItAway}
|
||||
<Alert type="warning" title="You will lose access to this" size="xs">
|
||||
<span class="font-mono">{pending?.change.role}</span> is not a role you can run as, so once
|
||||
it owns this you can no longer change its access — only a member of that role can hand it back.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if pendingCoversObjects}
|
||||
<Alert type="info" title="This covers every listed object" size="xs">
|
||||
Permissions on a single table are managed in that table's own permissions drawer.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#each pending?.warnings ?? [] as warning}
|
||||
<Alert type="warning" title="Warning" size="xs">{warning}</Alert>
|
||||
{/each}
|
||||
<span class="text-sm text-secondary">
|
||||
The following 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>
|
||||
</Portal>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { AclScope } from './aclScopes'
|
||||
import { privilegesOf, scopeSql, scopesOf } from './aclScopes'
|
||||
import type { AclTarget } from '$lib/gen'
|
||||
import Select from '../select/Select.svelte'
|
||||
import MultiSelect from '../select/MultiSelect.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
|
||||
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 scopeItems = $derived(
|
||||
scopesOf(target.kind).map((s) => ({ value: s.value, label: s.label }))
|
||||
)
|
||||
const available = $derived(privilegesOf(scope, target.kind, supportsMaintain))
|
||||
|
||||
// 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.
|
||||
$effect(() => {
|
||||
const allowed = privilegesOf(scope, target.kind, supportsMaintain)
|
||||
const kept = privileges.filter((p) => allowed.includes(p))
|
||||
if (kept.length !== privileges.length) privileges = kept
|
||||
})
|
||||
|
||||
const statement = $derived(
|
||||
privileges.length && role
|
||||
? `GRANT ${privileges.join(', ')} ON ${scopeSql(scope, target, dbname)} TO ${role}`
|
||||
: undefined
|
||||
)
|
||||
const canAdd = $derived(!!role && privileges.length > 0)
|
||||
</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}
|
||||
class="min-w-56"
|
||||
/>
|
||||
<span class="font-mono text-primary">ON</span>
|
||||
<Select bind:value={scope} items={scopeItems} clearable={false} {disabled} 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}
|
||||
class="w-40"
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={disabled || !canAdd}
|
||||
on:click={() => {
|
||||
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,157 @@
|
||||
import type { AclGrant, AclTarget } from '$lib/gen'
|
||||
|
||||
/** 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. `CONNECT` is
|
||||
* not offered: every role a data table creates is granted it at creation, so
|
||||
* building that statement by hand would only ever restate what is already true.
|
||||
* The backend still accepts it, so a CONNECT grant read off the catalog can be
|
||||
* revoked. */
|
||||
export const DATABASE_PRIVILEGES = ['CREATE', 'TEMPORARY']
|
||||
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
|
||||
}
|
||||
|
||||
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!)
|
||||
} else {
|
||||
rows.push({
|
||||
grantee: grant.grantee,
|
||||
privileges: grant.privileges,
|
||||
objects: grant.object ? [grant.object] : [],
|
||||
future: grant.future
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The scope a revoke of this row takes, or `undefined` when the builder cannot
|
||||
* express it — Postgres also records default privileges on types, which nothing
|
||||
* here grants and the API has no scope for. */
|
||||
export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined {
|
||||
if (!grant.future) return 'target'
|
||||
const scope = `future_${grant.future.toLowerCase()}`
|
||||
return (['future_tables', 'future_sequences', 'future_functions'] as const).find(
|
||||
(s) => s === scope
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 an annotation from the leading comment block', () => {
|
||||
expect(parseMigrationRole('-- role analyst\nBEGIN;\nSELECT 1;\nEND;')).toBe('analyst')
|
||||
expect(parseMigrationRole('\n\n-- role read-only_1\nBEGIN;')).toBe('read-only_1')
|
||||
})
|
||||
|
||||
test('stops at the first non-comment line', () => {
|
||||
// Inside the transaction it is prose, not a directive — and the executor
|
||||
// would not read it either.
|
||||
expect(parseMigrationRole('BEGIN;\n-- role analyst\nSELECT 1;')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('ignores prose that merely starts with role', () => {
|
||||
expect(parseMigrationRole('-- role based access is handled below\nBEGIN;')).toBeUndefined()
|
||||
expect(parseMigrationRole('-- role\nBEGIN;')).toBeUndefined()
|
||||
expect(parseMigrationRole('-- roles analyst\nBEGIN;')).toBeUndefined()
|
||||
expect(parseMigrationRole('-- role bad;name\nBEGIN;')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('no annotation means the default role', () => {
|
||||
expect(parseMigrationRole('BEGIN;\nSELECT 1;\nEND;')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withMigrationRole', () => {
|
||||
test('prepends above everything, so the executor still parses it', () => {
|
||||
const out = withMigrationRole('BEGIN;\nSELECT 1;\nEND;', 'analyst')
|
||||
expect(out.split('\n')[0]).toBe('-- role analyst')
|
||||
expect(parseMigrationRole(out)).toBe('analyst')
|
||||
})
|
||||
|
||||
test('replaces rather than stacks annotations', () => {
|
||||
const once = withMigrationRole('BEGIN;', 'analyst')
|
||||
const twice = withMigrationRole(once, 'auditor')
|
||||
expect(twice.match(/-- role /g)?.length).toBe(1)
|
||||
expect(parseMigrationRole(twice)).toBe('auditor')
|
||||
})
|
||||
|
||||
test('undefined strips the annotation back to the default role', () => {
|
||||
const stripped = withMigrationRole(withMigrationRole('BEGIN;\nEND;', 'analyst'), undefined)
|
||||
expect(parseMigrationRole(stripped)).toBeUndefined()
|
||||
expect(stripped).toBe('BEGIN;\nEND;')
|
||||
})
|
||||
|
||||
test('leaves other leading comments alone', () => {
|
||||
const out = withMigrationRole('-- adds an index\nBEGIN;', 'analyst')
|
||||
expect(out).toBe('-- role analyst\n-- adds an index\nBEGIN;')
|
||||
})
|
||||
|
||||
test('round-trips through the select both ways', () => {
|
||||
let code = 'BEGIN;\nCREATE TABLE t(x int);\nEND;'
|
||||
for (const role of ['analyst', 'auditor', undefined, 'admin']) {
|
||||
code = withMigrationRole(code, role)
|
||||
expect(parseMigrationRole(code)).toBe(role)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* A migration carries the data table role it runs as in its own SQL, as a
|
||||
* `-- role <name>` annotation above everything else. There is no separate field:
|
||||
* the annotation is what the postgres executor reads, and keeping it in the SQL
|
||||
* is what lets it survive a `wmill sync` round-trip, which only moves .sql files.
|
||||
*
|
||||
* These mirror `SqlAnnotations::datatable_role` on the backend — annotations are
|
||||
* only read from the leading comment block, and only an exact `-- role <name>`
|
||||
* line counts, so prose like `-- role based access is handled below` is not one.
|
||||
*/
|
||||
|
||||
const ROLE_NAME_RE = /^[a-zA-Z0-9_-]+$/
|
||||
|
||||
/** The role a migration declares, or undefined when it runs as the default. */
|
||||
export function parseMigrationRole(sql: string): string | undefined {
|
||||
for (const raw of sql.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (line === '') continue
|
||||
if (!line.startsWith('--')) break
|
||||
const tokens = line.slice(2).trim().split(/\s+/)
|
||||
if (tokens[0] === 'role' && tokens.length === 2 && ROLE_NAME_RE.test(tokens[1])) {
|
||||
return tokens[1]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `sql` declaring `role` — replacing any annotation it already carries,
|
||||
* or dropping it when `role` is undefined. The annotation has to lead: parsing
|
||||
* stops at the first non-comment line, and a migration's SQL usually opens with
|
||||
* `BEGIN`.
|
||||
*/
|
||||
export function withMigrationRole(sql: string, role: string | undefined): string {
|
||||
const lines = sql.split('\n')
|
||||
// Only the leading comment block can hold the annotation, so only strip there.
|
||||
let i = 0
|
||||
const kept: string[] = []
|
||||
for (; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (line === '') {
|
||||
kept.push(lines[i])
|
||||
continue
|
||||
}
|
||||
if (!line.startsWith('--')) break
|
||||
const tokens = line.slice(2).trim().split(/\s+/)
|
||||
const isRoleLine = tokens[0] === 'role' && tokens.length === 2 && ROLE_NAME_RE.test(tokens[1])
|
||||
if (!isRoleLine) kept.push(lines[i])
|
||||
}
|
||||
const rest = [...kept, ...lines.slice(i)]
|
||||
// Drop leading blank lines the removal may have left behind.
|
||||
while (rest.length > 0 && rest[0].trim() === '') rest.shift()
|
||||
return role ? [`-- role ${role}`, ...rest].join('\n') : rest.join('\n')
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
* firstSegment:
|
||||
* datatable – database with datatable:// resource (resourceType always postgresql)
|
||||
@@ -26,6 +26,10 @@ 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~~analyst (role only, no schema/table)
|
||||
*
|
||||
* role (fourth segment, optional, data tables only):
|
||||
* the data table role to connect as; omitted means its default role.
|
||||
*/
|
||||
|
||||
const dbManagerSchema = z.object({
|
||||
@@ -38,6 +42,7 @@ interface ParsedDbm {
|
||||
resType?: string
|
||||
schema?: string
|
||||
table?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
@@ -48,6 +53,7 @@ function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
const firstSeg = parts[0]
|
||||
const path = parts[1]
|
||||
const schemaTable = parts[2] ?? ''
|
||||
const role = parts[3] || undefined
|
||||
|
||||
let type: ParsedDbm['type']
|
||||
let resType: string | undefined
|
||||
@@ -81,7 +87,7 @@ function parseDbm(raw: unknown): ParsedDbm | null {
|
||||
schema = defaultSchemas[type]
|
||||
}
|
||||
|
||||
return { type, path, resType, schema, table }
|
||||
return { type, path, resType, schema, table, role }
|
||||
}
|
||||
|
||||
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
|
||||
@@ -97,6 +103,7 @@ function buildDbm(p: ParsedDbm): string {
|
||||
} else if (schema) {
|
||||
schemaTable = `${schema}.`
|
||||
}
|
||||
if (p.role) return `${firstSeg}~${p.path}~${schemaTable}~${p.role}`
|
||||
return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}`
|
||||
}
|
||||
|
||||
@@ -105,6 +112,8 @@ export interface DbManagerUriState {
|
||||
readonly effectiveInput: DbInput | undefined
|
||||
readonly isDatatableInput: boolean
|
||||
selectedDatatable: string | undefined
|
||||
/** 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 +146,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
type: 'database' as const,
|
||||
resourceType: resType as DbType,
|
||||
resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path,
|
||||
role: parsed.type === 'datatable' ? parsed.role : undefined,
|
||||
specificSchema: parsed.schema,
|
||||
specificTable: parsed.table
|
||||
}
|
||||
@@ -194,7 +204,14 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
return parsed?.type === 'datatable' ? parsed.path : undefined
|
||||
},
|
||||
set selectedDatatable(v: string | undefined) {
|
||||
if (v) updateField({ path: v })
|
||||
// Roles are per data table, so the current one cannot carry over.
|
||||
if (v) updateField({ path: v, role: undefined })
|
||||
},
|
||||
get selectedRole() {
|
||||
return parsed?.type === 'datatable' ? parsed.role : undefined
|
||||
},
|
||||
set selectedRole(v: string | undefined) {
|
||||
updateField({ role: v })
|
||||
},
|
||||
get selectedSchema() {
|
||||
return parsed?.schema
|
||||
|
||||
@@ -9,6 +9,7 @@ import { writingJobOptions } from './jobs/writingJob'
|
||||
import type { DBSchema, SQLSchema } from '$lib/stores'
|
||||
import { stringifySchema } from './copilot/lib'
|
||||
import type { DbInput, DbType } from './dbTypes'
|
||||
import { withMigrationRole } from './datatableMigrationRole'
|
||||
import { assert } from '$lib/utils'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils'
|
||||
@@ -245,6 +246,7 @@ export type IDbSchemaOps = {
|
||||
previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise<string>
|
||||
onCreateSchema: (params: { schema: string }) => Promise<void>
|
||||
onDeleteSchema: (params: { schema: string }) => Promise<void>
|
||||
onRenameSchema: (params: { schema: string; newSchema: string }) => Promise<void>
|
||||
onFetchTableEditorDefinition: (params: {
|
||||
table: string
|
||||
schema?: string
|
||||
@@ -288,6 +290,10 @@ export function dbSchemaOpsWithPreviewScripts({
|
||||
input.type === 'database' && input.resourcePath.startsWith('datatable://')
|
||||
? input.resourcePath.slice('datatable://'.length)
|
||||
: undefined
|
||||
// The role the manager is connected as. A migration generated here has to
|
||||
// declare it, or it would run as the data table's default role instead —
|
||||
// silently doing the DDL with privileges the user did not pick.
|
||||
const ambientRole = input.type === 'database' ? input.role : undefined
|
||||
|
||||
function makeMarker(op: string, payload: Record<string, unknown>): string {
|
||||
if (ducklake) payload.ducklake = ducklake
|
||||
@@ -368,12 +374,15 @@ export function dbSchemaOpsWithPreviewScripts({
|
||||
throw new MigrationRunCancelled()
|
||||
}
|
||||
}
|
||||
const codeUp = wrapMigration(await expandMarker(workspace, language, content))
|
||||
const codeUp = withMigrationRole(
|
||||
wrapMigration(await expandMarker(workspace, language, content)),
|
||||
ambientRole
|
||||
)
|
||||
// Down migrations are only generated for Postgres for now.
|
||||
let codeDown: string | undefined
|
||||
if (downContent && dbType === 'postgresql') {
|
||||
const downSql = (await expandMarker(workspace, language, downContent)).trim()
|
||||
if (downSql) codeDown = wrapMigration(downSql)
|
||||
if (downSql) codeDown = withMigrationRole(wrapMigration(downSql), ambientRole)
|
||||
}
|
||||
const created = await WorkspaceService.createDatatableMigration({
|
||||
workspace,
|
||||
@@ -454,6 +463,11 @@ export function dbSchemaOpsWithPreviewScripts({
|
||||
const downContent = makeMarker('CREATE_SCHEMA', { schema })
|
||||
await applyDdl(migrationName('drop_schema', schema), content, downContent)
|
||||
},
|
||||
onRenameSchema: async ({ schema, newSchema }) => {
|
||||
const content = makeMarker('RENAME_SCHEMA', { schema, new_schema: newSchema })
|
||||
const downContent = makeMarker('RENAME_SCHEMA', { schema: newSchema, new_schema: schema })
|
||||
await applyDdl(migrationName('rename_schema', newSchema), content, downContent)
|
||||
},
|
||||
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
|
||||
let foreignKeys: import('./apps/components/display/dbtable/tableEditor').TableEditorForeignKey[] =
|
||||
[]
|
||||
@@ -597,7 +611,9 @@ export function getDefaultDbTag(input: DbInput): string {
|
||||
export function getDatabaseArg(input: DbInput | undefined) {
|
||||
if (input?.type === 'database') {
|
||||
if (input.resourcePath.startsWith('datatable://')) {
|
||||
return { database: input.resourcePath }
|
||||
// The role rides in the reference: generated SQL has no natural place
|
||||
// for the `-- role` annotation a hand-written script would use.
|
||||
return { database: input.resourcePath + (input.role ? `?role=${input.role}` : '') }
|
||||
} else {
|
||||
return { database: '$res:' + input.resourcePath }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ export type DbInput =
|
||||
type: 'database'
|
||||
resourceType: DbType
|
||||
resourcePath: string
|
||||
/** Data table role to connect as. Only meaningful for a `datatable://`
|
||||
* resourcePath; absent means the data table's default role. */
|
||||
role?: string
|
||||
specificSchema?: string
|
||||
specificTable?: string
|
||||
}
|
||||
@@ -23,3 +26,10 @@ export const dbTypes = [
|
||||
'duckdb'
|
||||
] as const
|
||||
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
|
||||
|
||||
/** A row-menu action on a data table in the database manager's tree. */
|
||||
export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import'
|
||||
|
||||
/** The role a permissioned data table always has: the connection it resolved to
|
||||
* before permissions were turned on, so it owns every object created so far. */
|
||||
export const ADMIN_DATATABLE_ROLE = 'admin'
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
createDatatableAccessResource,
|
||||
createDatatablesResource,
|
||||
createSchemasResource,
|
||||
toDatatableItems,
|
||||
toSchemaItems
|
||||
} from './datatableUtils.svelte'
|
||||
@@ -20,6 +20,9 @@
|
||||
datatable: string | undefined
|
||||
/** Currently selected schema */
|
||||
schema: string | undefined
|
||||
/** The data table role the app's queries run as, if it names one. What a
|
||||
* role may see is what the schema list has to be read as. */
|
||||
role?: string | undefined
|
||||
/** Callback when either value changes */
|
||||
onChange?: (datatable: string | undefined, schema: string | undefined) => void
|
||||
/** Description text to show in the popover */
|
||||
@@ -29,19 +32,28 @@
|
||||
let {
|
||||
datatable,
|
||||
schema,
|
||||
role = undefined,
|
||||
onChange,
|
||||
description = 'Set the default datatable and schema for new tables. This is where AI will create new tables when needed.'
|
||||
}: Props = $props()
|
||||
|
||||
// Load available datatables and schemas using shared utilities
|
||||
const datatables = createDatatablesResource(() => opWs)
|
||||
const schemas = createSchemasResource(
|
||||
const access = createDatatableAccessResource(
|
||||
() => datatable,
|
||||
() => role,
|
||||
() => opWs
|
||||
)
|
||||
|
||||
const datatableItems = $derived(toDatatableItems(datatables.current))
|
||||
const schemaItems = $derived(toSchemaItems(schemas.current))
|
||||
// The answer says what it answers for: switching the database above leaves the
|
||||
// previous one's schemas in hand until the refetch lands, and picking from
|
||||
// them would name a schema this data table may not have.
|
||||
const schemaItems = $derived(
|
||||
access.current.datatable === datatable && access.current.role === role
|
||||
? toSchemaItems(access.current.schemas)
|
||||
: []
|
||||
)
|
||||
|
||||
// Track datatable changes to reset schema
|
||||
let previousDatatable = $state<string | undefined>(undefined)
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { DataTableRef } from './dataTableRefUtils'
|
||||
import { untrack } from 'svelte'
|
||||
import { resource } from 'runed'
|
||||
import { ArrowLeft, Expand, LoaderCircle, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
|
||||
import { ArrowLeft, Expand, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
|
||||
import DBManagerContent from '../DBManagerContent.svelte'
|
||||
import type { DbInput } from '../dbTypes'
|
||||
import type { SelectedTable } from '../DBManager.svelte'
|
||||
import type { PendingRowAction, SelectedTable } from '../DBManager.svelte'
|
||||
import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
|
||||
import { useDbManagerTag } from '../dbManagerTag.svelte'
|
||||
import DbWorkerTagButton from '../DbWorkerTagButton.svelte'
|
||||
import type { DataTableTables } from '$lib/gen'
|
||||
|
||||
const getOpWs = getRawAppOperatingWorkspace()
|
||||
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
|
||||
@@ -30,6 +31,8 @@
|
||||
|
||||
let open = $state(false)
|
||||
let selectedDatatable = $state<string | undefined>(undefined)
|
||||
/** Role the query editor connects as; undefined means the data table's default. */
|
||||
let selectedRole = $state<string | undefined>(undefined)
|
||||
|
||||
// For DB manager
|
||||
let dbManagerContent: DBManagerContent | undefined = $state()
|
||||
@@ -40,6 +43,9 @@
|
||||
// Multi-select mode: selected tables
|
||||
let selectedTables = $state<SelectedTable[]>([])
|
||||
|
||||
// Survives the re-mount a data table switch causes.
|
||||
let pendingAction = $state<PendingRowAction | undefined>(undefined)
|
||||
|
||||
// Selected schema/table from DBManager (for preview)
|
||||
let selectedSchemaKey = $state<string | undefined>(undefined)
|
||||
let selectedTableKey = $state<string | undefined>(undefined)
|
||||
@@ -55,15 +61,85 @@
|
||||
}
|
||||
})
|
||||
|
||||
export function openDrawer() {
|
||||
// Auto-select first datatable if only one exists
|
||||
if (datatables.current.length === 1) {
|
||||
selectedDatatable = datatables.current[0]
|
||||
} else if (datatables.current.length > 1 && datatables.current.includes('main')) {
|
||||
selectedDatatable = 'main'
|
||||
} else {
|
||||
selectedDatatable = undefined
|
||||
// Roles the *caller* may use, so the picker never offers one that would be
|
||||
// refused. Absent/disabled permissions yield no roles and hide the picker.
|
||||
const usableRoles = resource(
|
||||
() => [open, opWs, selectedDatatable] as const,
|
||||
async ([isOpen, workspace, datatable]) => {
|
||||
if (!isOpen || !workspace || !datatable) return undefined
|
||||
try {
|
||||
return {
|
||||
datatable,
|
||||
...(await WorkspaceService.listUsableDatatableRoles({
|
||||
workspace,
|
||||
datatableName: 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, enabled: false, roles: [], default_role: 'admin' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Roles are per data table, and a resource keeps its previous value while it
|
||||
// refetches.
|
||||
const rolesOfCurrent = $derived(
|
||||
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
|
||||
)
|
||||
|
||||
// The content must not mount until the role is settled: mounting is what fires
|
||||
// the schema and metadata queries, and a first round sent without a role would
|
||||
// run — and cache — as whatever the server defaults to.
|
||||
const roleSettled = $derived(
|
||||
selectedDatatable === undefined ||
|
||||
(rolesOfCurrent !== undefined &&
|
||||
(!rolesOfCurrent.enabled ||
|
||||
rolesOfCurrent.roles.length === 0 ||
|
||||
selectedRole !== undefined))
|
||||
)
|
||||
|
||||
// Settle the role before anything queries the data table: leaving it unset
|
||||
// until the user touches the picker would send the first — and cached — round
|
||||
// of queries as a role they may not be allowed to use.
|
||||
$effect(() => {
|
||||
const roles = rolesOfCurrent
|
||||
if (!roles?.enabled || selectedRole !== undefined) return
|
||||
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
|
||||
if (effective) untrack(() => (selectedRole = effective))
|
||||
})
|
||||
|
||||
// Every data table with its schemas and tables: the tree is the picker, so it
|
||||
// has to cover the data tables the query editor is not pointed at. Asked for
|
||||
// when the drawer opens — it reaches every data table's database in turn, and
|
||||
// the editor mounts this whether or not anyone opens it.
|
||||
const datatableTree = resource(
|
||||
// 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 — the same key its
|
||||
// sibling in the DB manager uses.
|
||||
() => [open, opWs, selectedDatatable, selectedRole] as const,
|
||||
async ([isOpen, workspace, roleFor, role]): Promise<DataTableTables[]> => {
|
||||
if (!isOpen || !workspace) return []
|
||||
try {
|
||||
return await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatable tables:', e)
|
||||
return []
|
||||
}
|
||||
},
|
||||
{ initialValue: [] }
|
||||
)
|
||||
|
||||
export function openDrawer() {
|
||||
// The tree shows every data table; this only picks which one the query
|
||||
// editor and the table preview run against.
|
||||
selectedDatatable = datatables.current.includes('main') ? 'main' : datatables.current[0]
|
||||
// A role belongs to the data table it was picked on, and this drawer outlives
|
||||
// the session that picked it: kept, it would query another data table under a
|
||||
// role of that name, or under one it has never heard of.
|
||||
selectedRole = undefined
|
||||
selectedSchemaKey = undefined
|
||||
selectedTableKey = undefined
|
||||
selectedTables = []
|
||||
@@ -71,15 +147,11 @@
|
||||
open = true
|
||||
}
|
||||
|
||||
let initialTableKey: string | undefined = $state<string | undefined>(undefined)
|
||||
let initialSchemaKey: string | undefined = $state<string | undefined>(undefined)
|
||||
|
||||
export function openDrawerWithRef(ref: DataTableRef) {
|
||||
selectedDatatable = ref.datatable
|
||||
selectedRole = undefined
|
||||
selectedSchemaKey = ref.schema
|
||||
selectedTableKey = ref.table
|
||||
initialTableKey = ref.table
|
||||
initialSchemaKey = ref.schema
|
||||
selectedTables = []
|
||||
expand = false
|
||||
open = true
|
||||
@@ -88,23 +160,22 @@
|
||||
export function closeDrawer() {
|
||||
open = false
|
||||
dbManagerContent?.clearReplResult()
|
||||
// Same reason as its sibling: an action outlives the data table it was
|
||||
// asked for otherwise.
|
||||
pendingAction = undefined
|
||||
}
|
||||
|
||||
function handleAddTables() {
|
||||
if (!selectedDatatable) {
|
||||
sendUserToast('Please select a data table first', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTables.length === 0) {
|
||||
sendUserToast('Please select at least one table', true)
|
||||
return
|
||||
}
|
||||
|
||||
// Add all selected tables
|
||||
for (const table of selectedTables) {
|
||||
const datatable = table.datatable ?? selectedDatatable
|
||||
if (!datatable) continue
|
||||
const ref: DataTableRef = {
|
||||
datatable: selectedDatatable,
|
||||
datatable,
|
||||
schema: table.schema,
|
||||
table: table.table
|
||||
}
|
||||
@@ -116,21 +187,17 @@
|
||||
selectedTables = []
|
||||
}
|
||||
|
||||
const datatableItems = $derived(
|
||||
datatables.current.map((dt) => ({
|
||||
value: dt,
|
||||
label: dt
|
||||
}))
|
||||
)
|
||||
|
||||
// Carries the picked schema/table, so a click on a row of another data table
|
||||
// lands on that table once the manager re-mounts against it.
|
||||
const dbInput: DbInput | undefined = $derived(
|
||||
selectedDatatable
|
||||
? {
|
||||
type: 'database' as const,
|
||||
resourceType: 'postgresql' as const,
|
||||
resourcePath: `datatable://${selectedDatatable}`,
|
||||
specificSchema: initialSchemaKey,
|
||||
specificTable: initialTableKey
|
||||
role: selectedRole,
|
||||
specificSchema: selectedSchemaKey,
|
||||
specificTable: selectedTableKey
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
@@ -141,15 +208,13 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Convert existingRefs to disabledTables format for the current datatable
|
||||
const disabledTables = $derived(
|
||||
existingRefs
|
||||
.filter((ref) => ref.datatable === selectedDatatable && ref.schema && ref.table)
|
||||
.map((ref) => ({ schema: ref.schema!, table: ref.table! }))
|
||||
.filter((ref) => ref.schema && ref.table)
|
||||
.map((ref) => ({ datatable: ref.datatable, schema: ref.schema!, table: ref.table! }))
|
||||
)
|
||||
|
||||
// Can add: has tables selected
|
||||
const canAdd = $derived(selectedDatatable && selectedTables.length > 0)
|
||||
const canAdd = $derived(selectedTables.length > 0)
|
||||
|
||||
// Shares the drawer-set override with the Database Manager: same data table,
|
||||
// same worker group needed to reach it.
|
||||
@@ -175,37 +240,27 @@
|
||||
noPadding
|
||||
>
|
||||
{#if dbInput && opWs}
|
||||
{#key selectedDatatable}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
input={dbInput}
|
||||
workspace={opWs}
|
||||
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey
|
||||
bind:selectedTableKey
|
||||
multiSelectMode={true}
|
||||
bind:selectedTables
|
||||
{disabledTables}
|
||||
>
|
||||
{#snippet dbSelector()}
|
||||
{#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={selectedDatatable}
|
||||
placeholder="Select data table"
|
||||
size="md"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DBManagerContent>
|
||||
{/key}
|
||||
{#if roleSettled}
|
||||
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
|
||||
<DBManagerContent
|
||||
bind:this={dbManagerContent}
|
||||
input={dbInput}
|
||||
workspace={opWs}
|
||||
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey
|
||||
bind:selectedTableKey
|
||||
multiSelectMode={true}
|
||||
bind:selectedTables
|
||||
{disabledTables}
|
||||
datatableTree={datatableTree.current}
|
||||
datatableTreeLoading={datatableTree.loading}
|
||||
onSelectDatatable={(dt) => ((selectedDatatable = dt), (selectedRole = undefined))}
|
||||
onSelectRole={(dt, role) => ((selectedDatatable = dt), (selectedRole = role))}
|
||||
bind:pendingAction
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full text-tertiary">
|
||||
<span>Select a data table to explore</span>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
defaultDatatable?: string | undefined
|
||||
/** Default schema for new tables */
|
||||
defaultSchema?: string | undefined
|
||||
/** The data table role the app's queries run as, if it names one. */
|
||||
defaultRole?: string | undefined
|
||||
onAdd?: () => void
|
||||
onRemove?: (index: number) => void
|
||||
onSelect?: (ref: DataTableRef, index: number) => void
|
||||
@@ -31,6 +33,7 @@
|
||||
dataTableRefs = [],
|
||||
defaultDatatable = undefined,
|
||||
defaultSchema = undefined,
|
||||
defaultRole = undefined,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onSelect,
|
||||
@@ -95,6 +98,7 @@
|
||||
<DefaultDatabaseSelector
|
||||
datatable={defaultDatatable}
|
||||
schema={defaultSchema}
|
||||
role={defaultRole}
|
||||
onChange={onDefaultChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { roleAfterDatatableChange } from './datatableUtils.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { paneMinPercent } from '$lib/utils/splitpaneSizing'
|
||||
import RawAppInlineScriptsPanel from './RawAppInlineScriptsPanel.svelte'
|
||||
@@ -282,6 +283,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The role the app is saved with applies to its own data table; another data
|
||||
// table named by a tool is read as its default role, like everywhere else.
|
||||
function appRoleFor(datatableName: string): string | undefined {
|
||||
return datatableName === data.datatable ? data.role : undefined
|
||||
}
|
||||
|
||||
function isDatatableTableWhitelisted(
|
||||
datatableName: string,
|
||||
schemaName: string,
|
||||
@@ -878,7 +885,8 @@
|
||||
aiChatManager.datatableCreationPolicy = {
|
||||
enabled: data.datatable !== undefined,
|
||||
datatable: data.datatable,
|
||||
schema: data.schema
|
||||
schema: data.schema,
|
||||
role: data.role
|
||||
}
|
||||
|
||||
// Start auto-snapshot
|
||||
@@ -900,9 +908,14 @@
|
||||
// Read the current policy from aiChatManager
|
||||
const policy = aiChatManager.datatableCreationPolicy
|
||||
// Only update if different to avoid infinite loops
|
||||
if (data.datatable !== policy.datatable || data.schema !== policy.schema) {
|
||||
if (
|
||||
data.datatable !== policy.datatable ||
|
||||
data.schema !== policy.schema ||
|
||||
data.role !== policy.role
|
||||
) {
|
||||
data.datatable = policy.datatable
|
||||
data.schema = policy.schema
|
||||
data.role = policy.role
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1079,8 +1092,12 @@
|
||||
return []
|
||||
}
|
||||
|
||||
// The app's data table is read as the role the app runs as, so the AI
|
||||
// sees the tables that role reaches, not the default role's.
|
||||
const tables = await WorkspaceService.listDataTableTables({
|
||||
workspace: opWorkspace
|
||||
workspace: opWorkspace,
|
||||
roleFor: data.role ? data.datatable : undefined,
|
||||
role: data.role
|
||||
})
|
||||
return filterDatatableTables(tables)
|
||||
},
|
||||
@@ -1106,7 +1123,8 @@
|
||||
workspace: opWorkspace,
|
||||
datatableName,
|
||||
schemaName,
|
||||
tableName
|
||||
tableName,
|
||||
role: appRoleFor(datatableName)
|
||||
})
|
||||
return schema.columns
|
||||
},
|
||||
@@ -1124,13 +1142,16 @@
|
||||
}
|
||||
|
||||
try {
|
||||
// Same reference the generated runnables use: the role rides in it, so
|
||||
// a table the AI creates belongs to the role the app will connect as.
|
||||
const role = appRoleFor(datatableName)
|
||||
const result = await runScriptAndPollResult(
|
||||
{
|
||||
workspace: opWorkspace,
|
||||
requestBody: {
|
||||
language: 'postgresql',
|
||||
content: sql,
|
||||
args: { database: `datatable://${datatableName}` }
|
||||
args: { database: `datatable://${datatableName}${role ? `?role=${role}` : ''}` }
|
||||
}
|
||||
},
|
||||
writingJobOptions
|
||||
@@ -2358,14 +2379,18 @@
|
||||
}}
|
||||
defaultDatatable={data.datatable}
|
||||
defaultSchema={data.schema}
|
||||
defaultRole={data.role}
|
||||
onDefaultChange={(datatable, schema) => {
|
||||
const role = roleAfterDatatableChange(data.datatable, datatable, data.role)
|
||||
data.datatable = datatable
|
||||
data.schema = schema
|
||||
data.role = role
|
||||
// Also sync to aiChatManager
|
||||
aiChatManager.datatableCreationPolicy = {
|
||||
...aiChatManager.datatableCreationPolicy,
|
||||
datatable,
|
||||
schema
|
||||
schema,
|
||||
role
|
||||
}
|
||||
}}
|
||||
{runnables}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
defaultDatatable?: string | undefined
|
||||
/** Default schema for new tables */
|
||||
defaultSchema?: string | undefined
|
||||
defaultRole?: string | undefined
|
||||
onDefaultChange?: (datatable: string | undefined, schema: string | undefined) => void
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@
|
||||
onDataTableRefsChange,
|
||||
defaultDatatable = undefined,
|
||||
defaultSchema = undefined,
|
||||
defaultRole = undefined,
|
||||
onDefaultChange
|
||||
}: Props = $props()
|
||||
|
||||
@@ -153,6 +155,7 @@
|
||||
{dataTableRefs}
|
||||
{defaultDatatable}
|
||||
{defaultSchema}
|
||||
{defaultRole}
|
||||
onAdd={() => dataTableDrawer?.openDrawer()}
|
||||
onRemove={handleRemoveDataTable}
|
||||
onSelect={handleSelectDataTable}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { Sparkles, Plus, List, Ban, ExternalLinkIcon, Loader2 } from 'lucide-svelte'
|
||||
import type { Policy } from '$lib/gen'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
@@ -21,8 +22,10 @@
|
||||
import type { Runnable } from './rawAppPolicy'
|
||||
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
|
||||
import {
|
||||
createDatatableAccessResource,
|
||||
createDatatablesResource,
|
||||
createSchemasResource,
|
||||
createRolesResource,
|
||||
rolesWorthPicking,
|
||||
toDatatableItems,
|
||||
toSchemaItems
|
||||
} from './datatableUtils.svelte'
|
||||
@@ -68,13 +71,119 @@
|
||||
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
|
||||
|
||||
const datatables = createDatatablesResource(() => opWs)
|
||||
const schemas = createSchemasResource(
|
||||
// What the picked role can reach, which is not what the data table holds.
|
||||
const access = createDatatableAccessResource(
|
||||
() => selectedDatatable,
|
||||
() => accessRole,
|
||||
() => opWs
|
||||
)
|
||||
const roles = createRolesResource(
|
||||
() => selectedDatatable,
|
||||
() => opWs
|
||||
)
|
||||
|
||||
let selectedRole = $state<string | undefined>(undefined)
|
||||
|
||||
// A resource answers for the data table (and role) it was asked about, and
|
||||
// says which. Anything read before its answer matches the selection belongs to
|
||||
// the previous data table, to another role, or to no question at all — so
|
||||
// every reader below waits for the stamp rather than trusting `current`.
|
||||
const loadedRoles = $derived(
|
||||
roles.current.datatable === selectedDatatable ? roles.current.roles : []
|
||||
)
|
||||
const showRolePicker = $derived(rolesWorthPicking(loadedRoles))
|
||||
const effectiveRole = $derived(
|
||||
selectedRole !== undefined && loadedRoles.includes(selectedRole) ? selectedRole : undefined
|
||||
)
|
||||
|
||||
// The role this app is created with, written down rather than left to resolve.
|
||||
// Leaving it out means "whatever the default is then", which silently moves the
|
||||
// app the day an admin changes it — and that default is not filtered by what
|
||||
// this caller may use in the first place.
|
||||
const roleToSave = $derived(effectiveRole)
|
||||
|
||||
const availableDatatables = $derived(datatables.current)
|
||||
const availableSchemas = $derived(schemas.current)
|
||||
// `undefined` while the list loads, so this is false until it has answered.
|
||||
const hasNoDatatables = $derived(availableDatatables?.length === 0)
|
||||
|
||||
// A workspace with no data tables has nothing to wait for; anything else waits
|
||||
// for the answer about what is selected.
|
||||
const rolesSettled = $derived(
|
||||
hasNoDatatables ||
|
||||
(selectedDatatable !== undefined && roles.current.datatable === selectedDatatable)
|
||||
)
|
||||
|
||||
// The picked role belongs to the data table it was picked on: two data tables
|
||||
// can both define an `analyst` that means something different, so a name
|
||||
// surviving the switch is not the role surviving it.
|
||||
let rolesPickedOn = $state<string | undefined>(undefined)
|
||||
$effect(() => {
|
||||
const loaded = roles.current
|
||||
if (loaded.datatable !== selectedDatatable) return
|
||||
const switched = untrack(() => rolesPickedOn) !== selectedDatatable
|
||||
if (switched || selectedRole === undefined || !loaded.roles.includes(selectedRole)) {
|
||||
selectedRole = loaded.roles.includes(loaded.defaultRole)
|
||||
? loaded.defaultRole
|
||||
: loaded.roles[0]
|
||||
rolesPickedOn = selectedDatatable
|
||||
}
|
||||
})
|
||||
|
||||
// Asked as the role the app will run as, picker or no picker: hiding a choice
|
||||
// there is only one of is not the same as having no role.
|
||||
const accessRole = $derived(effectiveRole)
|
||||
// Permissioned, and this caller may run as none of its roles. Leaving the role
|
||||
// out would save the data table's configured default, which is exactly the one
|
||||
// they cannot use, so the app would be created with queries that are refused.
|
||||
const noUsableRole = $derived(
|
||||
rolesSettled &&
|
||||
selectedDatatable !== undefined &&
|
||||
roles.current.permissioned &&
|
||||
loadedRoles.length === 0
|
||||
)
|
||||
// A lookup that failed is not an answer that there is nothing to pick, and an
|
||||
// app naming this data table would be created against whatever the default
|
||||
// turns out to be.
|
||||
const rolesUnknown = $derived(rolesSettled && roles.current.failed)
|
||||
const accessSettled = $derived(
|
||||
hasNoDatatables ||
|
||||
(rolesSettled &&
|
||||
selectedDatatable !== undefined &&
|
||||
access.current.datatable === selectedDatatable &&
|
||||
access.current.role === accessRole)
|
||||
)
|
||||
const loadedAccess = $derived(
|
||||
accessSettled
|
||||
? access.current
|
||||
: {
|
||||
datatable: selectedDatatable,
|
||||
role: accessRole,
|
||||
failed: false,
|
||||
schemas: [],
|
||||
canCreateSchema: false
|
||||
}
|
||||
)
|
||||
// Nothing was learned about what this role may do here — the request failed, or
|
||||
// the backend could not reach that database. `canCreateSchema: false` is then
|
||||
// the absence of an answer, not a refusal to act on.
|
||||
const accessUnknown = $derived(accessSettled && loadedAccess.failed)
|
||||
const availableSchemas = $derived(loadedAccess.schemas)
|
||||
const canCreateSchema = $derived(loadedAccess.canCreateSchema)
|
||||
|
||||
// Only the app that will name the data table is refused: with table creation
|
||||
// off nothing saves it, and an app that does not touch it is not this caller's
|
||||
// problem to be stopped over.
|
||||
const blockedByRole = $derived(
|
||||
(noUsableRole || rolesUnknown || accessUnknown) && tableCreationEnabled
|
||||
)
|
||||
|
||||
// A role that cannot create schemas has nothing to name, so the mode goes back
|
||||
// to the one every role has — once that is an answer rather than the absence
|
||||
// of one, since nothing puts `new` back afterwards.
|
||||
$effect(() => {
|
||||
if (accessSettled && !accessUnknown && schemaMode === 'new' && !canCreateSchema)
|
||||
schemaMode = 'none'
|
||||
})
|
||||
|
||||
let hasAutoSelected = false
|
||||
$effect(() => {
|
||||
@@ -119,7 +228,6 @@
|
||||
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
|
||||
)
|
||||
|
||||
const hasNoDatatables = $derived(availableDatatables?.length === 0)
|
||||
|
||||
// copilotInfo is a global that stays empty until some ancestor's fetch lands, so
|
||||
// `enabled` alone cannot tell "no providers" from "not loaded yet" and the modal
|
||||
@@ -140,7 +248,10 @@
|
||||
async function start(withPrompt: boolean) {
|
||||
const template = templates[selectedTemplateIndex]
|
||||
|
||||
if (schemaMode === 'new' && newSchemaName && selectedDatatable && opWs) {
|
||||
// Only for an app that keeps the data table: with creation off nothing names
|
||||
// it, so making a schema in it is work the app will never refer to — and on
|
||||
// a database that could not be read, a failure the user cannot act on.
|
||||
if (tableCreationEnabled && schemaMode === 'new' && newSchemaName && selectedDatatable && opWs) {
|
||||
try {
|
||||
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
|
||||
const dbOps = dbSchemaOpsWithPreviewScripts({
|
||||
@@ -148,7 +259,8 @@
|
||||
input: {
|
||||
type: 'database',
|
||||
resourceType: 'postgresql',
|
||||
resourcePath: `datatable://${selectedDatatable}`
|
||||
resourcePath: `datatable://${selectedDatatable}`,
|
||||
role: roleToSave
|
||||
}
|
||||
})
|
||||
await dbOps.onCreateSchema({ schema: newSchemaName })
|
||||
@@ -164,9 +276,10 @@
|
||||
? {
|
||||
tables: formattedTables,
|
||||
datatable: selectedDatatable,
|
||||
schema: effectiveSchema
|
||||
schema: effectiveSchema,
|
||||
role: roleToSave
|
||||
}
|
||||
: { tables: formattedTables, datatable: undefined, schema: undefined }
|
||||
: { tables: formattedTables, datatable: undefined, schema: undefined, role: undefined }
|
||||
|
||||
const policy: Policy = {
|
||||
on_behalf_of: $userStore?.username.includes('@')
|
||||
@@ -259,15 +372,45 @@
|
||||
<label class="text-xs text-emphasis font-semibold" for="datatable"
|
||||
>Datatable</label
|
||||
>
|
||||
<Select
|
||||
id="datatable"
|
||||
disablePortal
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
placeholder="Datatable"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Select
|
||||
id="datatable"
|
||||
disablePortal
|
||||
items={datatableItems}
|
||||
bind:value={selectedDatatable}
|
||||
placeholder="Datatable"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
{#if noUsableRole || rolesUnknown || accessUnknown}
|
||||
<!-- With no usable role the access question goes out role-less,
|
||||
which the server answers as the default role — the one that was
|
||||
refused — so `accessUnknown` is true here too and says the less
|
||||
specific thing. -->
|
||||
<span class="text-xs text-red-600 dark:text-red-400">
|
||||
{rolesUnknown
|
||||
? 'could not read its roles'
|
||||
: noUsableRole
|
||||
? 'no role you can use'
|
||||
: 'could not reach it'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if showRolePicker}
|
||||
<!-- Reads as one phrase — "main as admin" — so the role needs no
|
||||
label of its own. -->
|
||||
<span class="text-xs text-secondary">as</span>
|
||||
<Select
|
||||
id="datatable-role"
|
||||
disablePortal
|
||||
items={loadedRoles.map((r) => ({ value: r, label: r }))}
|
||||
bind:value={selectedRole}
|
||||
clearable={false}
|
||||
placeholder="Role"
|
||||
size="sm"
|
||||
class="w-40"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-emphasis font-semibold">Schema</span>
|
||||
@@ -276,7 +419,21 @@
|
||||
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
|
||||
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
|
||||
<ToggleButton
|
||||
value="new"
|
||||
label="New"
|
||||
icon={Plus}
|
||||
disabled={!canCreateSchema}
|
||||
tooltip={canCreateSchema
|
||||
? undefined
|
||||
: noUsableRole
|
||||
? `You may run as no role on ${selectedDatatable}`
|
||||
: accessUnknown
|
||||
? `Could not read what may be created in ${selectedDatatable}`
|
||||
: `${effectiveRole ?? 'This role'} cannot create schemas in ${selectedDatatable}`}
|
||||
{item}
|
||||
size="sm"
|
||||
/>
|
||||
<ToggleButton
|
||||
value="existing"
|
||||
label="Existing"
|
||||
@@ -418,7 +575,11 @@
|
||||
variant="default"
|
||||
size="sm"
|
||||
on:click={() => start(false)}
|
||||
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
|
||||
disabled={!templates[selectedTemplateIndex] ||
|
||||
newSchemaAlreadyExists ||
|
||||
!rolesSettled ||
|
||||
!accessSettled ||
|
||||
blockedByRole}
|
||||
>
|
||||
{$copilotInfo.workspaceDisabled ? 'Start' : 'Start without AI'}
|
||||
</Button>
|
||||
@@ -426,7 +587,10 @@
|
||||
<Button
|
||||
variant="accent"
|
||||
on:click={() => start(true)}
|
||||
disabled={!templates[selectedTemplateIndex] ||
|
||||
disabled={!rolesSettled ||
|
||||
!accessSettled ||
|
||||
blockedByRole ||
|
||||
!templates[selectedTemplateIndex] ||
|
||||
!initialPrompt.trim() ||
|
||||
newSchemaAlreadyExists}
|
||||
startIcon={{ icon: Sparkles }}
|
||||
|
||||
@@ -16,13 +16,17 @@ export interface RawAppData {
|
||||
datatable: string | undefined
|
||||
/** The schema for table creation (if specified) */
|
||||
schema: string | undefined
|
||||
/** The data table role the app's queries and table creation run as; absent
|
||||
* means the data table's default one. */
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** Default data configuration */
|
||||
export const DEFAULT_DATA: RawAppData = {
|
||||
tables: [],
|
||||
datatable: undefined,
|
||||
schema: undefined
|
||||
schema: undefined,
|
||||
role: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { resource } from 'runed'
|
||||
import { workspaceStore, dbSchemas } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
|
||||
import { ADMIN_DATATABLE_ROLE } from '$lib/components/dbTypes'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
/**
|
||||
* The role an app keeps when its default data table changes.
|
||||
*
|
||||
* A data table role is defined on one data table, so it does not follow the
|
||||
* default to another: kept, the app's queries would name a role that data table
|
||||
* has never heard of, and the one it names here may not be the one it gets.
|
||||
*/
|
||||
export function roleAfterDatatableChange(
|
||||
previous: string | undefined,
|
||||
next: string | undefined,
|
||||
role: string | undefined
|
||||
): string | undefined {
|
||||
return next === previous ? role : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a resource that loads available datatables from the workspace.
|
||||
* Pass a getter function that returns the workspace to create a reactive dependency.
|
||||
@@ -21,42 +36,137 @@ export function createDatatablesResource(getWorkspace: () => string | undefined)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a resource that loads schemas for a given datatable.
|
||||
* The getDatatable getter is used as a reactive dependency - when it changes, schemas are refetched.
|
||||
* Creates a resource that loads the roles the caller may use on a datatable,
|
||||
* and the one it defaults to.
|
||||
*/
|
||||
export function createSchemasResource(
|
||||
export function createRolesResource(
|
||||
getDatatable: () => string | undefined,
|
||||
getWorkspace: () => string | undefined = () => get(workspaceStore)
|
||||
) {
|
||||
return resource<string[]>([() => getDatatable() ?? '', () => getWorkspace() ?? ''], async () => {
|
||||
const datatable = getDatatable()
|
||||
const workspace = getWorkspace()
|
||||
if (!datatable || !workspace) return []
|
||||
|
||||
const resourcePath = `datatable://${datatable}`
|
||||
// Key the schema cache by workspace too: a datatable of the same name can
|
||||
// exist in both the nav and the acting workspace, so `datatable://<name>`
|
||||
// alone would let one workspace's schema be reused for the other.
|
||||
const cacheKey = `${workspace}:${resourcePath}`
|
||||
const schemas = get(dbSchemas)
|
||||
let dbSchema = schemas[cacheKey]
|
||||
|
||||
if (!dbSchema) {
|
||||
return resource(
|
||||
() => [getDatatable() ?? '', getWorkspace() ?? ''] as const,
|
||||
async ([datatableName, workspace]): Promise<{
|
||||
/** The data table this answers for: while a switch is in flight the
|
||||
* previous one's roles are still what `current` holds, and they say
|
||||
* nothing about the one now selected. */
|
||||
datatable: string | undefined
|
||||
/** Whether the data table has permissions on. With none, every caller
|
||||
* reaches it through its own connection and `roles` is empty because
|
||||
* there is nothing to pick — which is not the same as a permissioned one
|
||||
* this caller may run as nothing on. */
|
||||
permissioned: boolean
|
||||
/** The lookup itself failed, so neither of the above is an answer: an
|
||||
* empty `roles` here means nothing was learned, not that there is
|
||||
* nothing to pick. */
|
||||
failed: boolean
|
||||
roles: string[]
|
||||
defaultRole: string
|
||||
}> => {
|
||||
const empty = {
|
||||
datatable: datatableName || undefined,
|
||||
permissioned: false,
|
||||
failed: false,
|
||||
roles: [],
|
||||
defaultRole: ADMIN_DATATABLE_ROLE
|
||||
}
|
||||
if (!datatableName || !workspace) return empty
|
||||
try {
|
||||
schemas[cacheKey] = await getDbSchemas('postgresql', resourcePath, workspace, (msg) =>
|
||||
console.error('Schema error:', msg)
|
||||
)
|
||||
dbSchema = get(dbSchemas)[cacheKey]
|
||||
const res = await WorkspaceService.listUsableDatatableRoles({ workspace, datatableName })
|
||||
return {
|
||||
...empty,
|
||||
permissioned: res.enabled,
|
||||
roles: res.enabled ? res.roles : [],
|
||||
defaultRole: res.default_role
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load schema for ${datatable}:`, e)
|
||||
return []
|
||||
console.error('Failed to load datatable roles:', e)
|
||||
return { ...empty, failed: true }
|
||||
}
|
||||
},
|
||||
{
|
||||
initialValue: {
|
||||
datatable: undefined,
|
||||
permissioned: false,
|
||||
failed: false,
|
||||
roles: [],
|
||||
defaultRole: ADMIN_DATATABLE_ROLE
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!dbSchema?.schema) return []
|
||||
return Object.keys(dbSchema.schema)
|
||||
})
|
||||
/**
|
||||
* Creates a resource that loads, for one data table read as one role, the
|
||||
* schemas that role can reach and whether it may create more.
|
||||
*
|
||||
* Both answers are the connected role's, and one call carries them: asking the
|
||||
* schema list of a role that cannot see a schema is the same question as asking
|
||||
* what it may create in.
|
||||
*/
|
||||
export function createDatatableAccessResource(
|
||||
getDatatable: () => string | undefined,
|
||||
getRole: () => string | undefined,
|
||||
getWorkspace: () => string | undefined = () => get(workspaceStore)
|
||||
) {
|
||||
return resource(
|
||||
() => [getDatatable() ?? '', getRole() ?? '', getWorkspace() ?? ''] as const,
|
||||
async ([datatable, role, workspace]): Promise<{
|
||||
/** What this answers for — both halves of it. Until they match the
|
||||
* selection, the schemas and the right to create one belong to another
|
||||
* data table or to another role, and at mount they are the initial value
|
||||
* rather than an answer at all. What a role may create in is exactly the
|
||||
* question, so an answer computed as a different one settles nothing. */
|
||||
datatable: string | undefined
|
||||
role: string | undefined
|
||||
/** The lookup did not answer — the request failed, or the backend kept the
|
||||
* entry with an error because it could not reach that database. An empty
|
||||
* `schemas` then means nothing was learned, and `canCreateSchema: false`
|
||||
* is the absence of an answer rather than a refusal by the role. */
|
||||
failed: boolean
|
||||
schemas: string[]
|
||||
canCreateSchema: boolean
|
||||
}> => {
|
||||
const asked = { datatable: datatable || undefined, role: role || undefined, failed: false }
|
||||
if (!datatable || !workspace) return { ...asked, schemas: [], canCreateSchema: false }
|
||||
try {
|
||||
const tables = await WorkspaceService.listDataTableTables({
|
||||
workspace,
|
||||
roleFor: datatable,
|
||||
role: role || undefined
|
||||
})
|
||||
const entry = tables.find((t) => t.datatable_name === datatable)
|
||||
return {
|
||||
...asked,
|
||||
failed: entry === undefined || entry.error !== undefined,
|
||||
schemas: Object.keys(entry?.schemas ?? {}).sort(),
|
||||
canCreateSchema: !!entry?.can_create_schema
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatable access:', e)
|
||||
return { ...asked, failed: true, schemas: [], canCreateSchema: false }
|
||||
}
|
||||
},
|
||||
{
|
||||
initialValue: {
|
||||
datatable: undefined,
|
||||
role: undefined,
|
||||
failed: false,
|
||||
schemas: [],
|
||||
canCreateSchema: false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether naming a role says anything here: a data table without permissions has
|
||||
* none to pick, and one whose single role is the implicit `admin` has no choice
|
||||
* to offer.
|
||||
*/
|
||||
export function rolesWorthPicking(roles: string[]): boolean {
|
||||
return roles.length > 1 || (roles.length === 1 && roles[0] !== ADMIN_DATATABLE_ROLE)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { roleAfterDatatableChange } from './datatableUtils.svelte'
|
||||
|
||||
describe('roleAfterDatatableChange', () => {
|
||||
it('drops a role that belongs to the data table being left', () => {
|
||||
// The role exists on `main` and says nothing about `second`, so an app
|
||||
// whose default moves must not keep naming it.
|
||||
expect(roleAfterDatatableChange('main', 'second', 'analyst')).toBe(undefined)
|
||||
expect(roleAfterDatatableChange('main', undefined, 'analyst')).toBe(undefined)
|
||||
})
|
||||
|
||||
it('keeps it when only the schema moved', () => {
|
||||
expect(roleAfterDatatableChange('main', 'main', 'analyst')).toBe('analyst')
|
||||
expect(roleAfterDatatableChange(undefined, undefined, undefined)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
@@ -169,6 +169,11 @@
|
||||
|
||||
// Open the list modal and the detail view for a specific migration. Used to
|
||||
// jump to a just-created migration from the "See migration" toast action.
|
||||
/** Open the migrations list without the trigger button (tree menus drive it). */
|
||||
export function open() {
|
||||
openList()
|
||||
}
|
||||
|
||||
export async function openMigration(timestamp: number) {
|
||||
listOpen = true
|
||||
await loadMigrations()
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '../common'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import MultiSelect from '../select/MultiSelect.svelte'
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
import Checkbox from '../common/checkbox/Checkbox.svelte'
|
||||
import Cell from '../table/Cell.svelte'
|
||||
import DataTable from '../table/DataTable.svelte'
|
||||
import Head from '../table/Head.svelte'
|
||||
import Row from '../table/Row.svelte'
|
||||
import { KeyRound, Plus } from 'lucide-svelte'
|
||||
import {
|
||||
FolderService,
|
||||
GroupService,
|
||||
UserService,
|
||||
WorkspaceService,
|
||||
type DatatableRoleInfo
|
||||
} from '$lib/gen'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { ADMIN_DATATABLE_ROLE } from '../dbTypes'
|
||||
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
disabled = false,
|
||||
hideTrigger = false,
|
||||
onSaved
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
disabled?: boolean
|
||||
/** Mount the drawer without its button, so a caller can drive it via `open()`
|
||||
* (the database manager opens it from the tree's row menu). */
|
||||
hideTrigger?: boolean
|
||||
/** Called once the roles have actually changed, so a caller showing them
|
||||
* (the manager's role picker) can read them again. */
|
||||
onSaved?: () => void
|
||||
} = $props()
|
||||
|
||||
// The server refuses to turn permissions on from a fork: its data table points
|
||||
// either at the database of the workspace it was forked from, which is where
|
||||
// to set them, or at a copy the fork can drop.
|
||||
const isFork = $derived(
|
||||
!!$userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id
|
||||
)
|
||||
// Permissions belong to the database, and are managed from the workspace that
|
||||
// turned them on; from any other workspace reaching the same database they are
|
||||
// read-only here.
|
||||
let editable = $state(true)
|
||||
let ownerWorkspace = $state<string | undefined>(undefined)
|
||||
|
||||
// Matches every workspace member, unlike the `all` group whose membership is
|
||||
// bookkeeping that can drift.
|
||||
const WILDCARD_TENANT = '*'
|
||||
|
||||
// Stable client-side id so a rename (A -> B) is sent as a rename rather than
|
||||
// read as a delete plus an add, which would drop the role's grants.
|
||||
type EditedRole = { id: string; name: string; tenants: string[]; pg_rolename?: string }
|
||||
|
||||
let open = $state(false)
|
||||
let loading = $state(false)
|
||||
let saving = $state(false)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
|
||||
let enabled = $state(false)
|
||||
let roles = $state<EditedRole[]>([])
|
||||
/** Tracked by id, not name, so renaming the default role keeps it selected. */
|
||||
let defaultRoleId = $state<string | undefined>(undefined)
|
||||
/** The last loaded state, to diff renames and detect unsaved changes against. */
|
||||
let saved = $state<{ enabled: boolean; roles: EditedRole[]; defaultRoleId?: string }>({
|
||||
enabled: false,
|
||||
roles: []
|
||||
})
|
||||
|
||||
// Tenants are principals of the workspace that owns the permissions, which
|
||||
// is this one unless a superadmin manages them from another reaching the
|
||||
// same database.
|
||||
const tenantItems = resource([() => ownerWorkspace ?? workspace], async ([ws]) => {
|
||||
if (!ws) return []
|
||||
const [users, groups, folders] = await Promise.all([
|
||||
UserService.listUsernames({ workspace: ws }),
|
||||
GroupService.listGroupNames({ workspace: ws }),
|
||||
FolderService.listFolderNames({ workspace: ws })
|
||||
])
|
||||
return [
|
||||
{ value: WILDCARD_TENANT, label: 'Everyone', group: 'Anyone in the workspace' },
|
||||
...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })),
|
||||
...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })),
|
||||
...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' }))
|
||||
]
|
||||
})
|
||||
|
||||
function toEdited(role: DatatableRoleInfo): EditedRole {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
name: role.name,
|
||||
tenants: [...(role.tenants ?? [])],
|
||||
pg_rolename: role.pg_rolename
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
loadError = undefined
|
||||
try {
|
||||
const res = await WorkspaceService.getDatatablePermissions({
|
||||
workspace,
|
||||
datatableName: datatable
|
||||
})
|
||||
// The backend returns roles in name order; admin leads the list instead,
|
||||
// since it is the one every other role is defined against.
|
||||
const loaded = res.roles
|
||||
.map(toEdited)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.name === ADMIN_DATATABLE_ROLE) - Number(a.name === ADMIN_DATATABLE_ROLE)
|
||||
)
|
||||
// A data table that has never been opted in comes back with no roles;
|
||||
// showing admin straight away is what the toggle is about to create.
|
||||
if (!loaded.some((r) => r.name === ADMIN_DATATABLE_ROLE)) {
|
||||
loaded.unshift({ id: randomUUID(), name: ADMIN_DATATABLE_ROLE, tenants: [] })
|
||||
}
|
||||
enabled = res.enabled
|
||||
editable = res.editable
|
||||
ownerWorkspace = res.owner_workspace_id
|
||||
roles = loaded
|
||||
defaultRoleId = loaded.find((r) => r.name === res.default_role)?.id ?? loaded[0]?.id
|
||||
saved = {
|
||||
enabled: res.enabled,
|
||||
roles: structuredClone($state.snapshot(loaded)),
|
||||
defaultRoleId
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e?.body ?? e?.message ?? String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
open = true
|
||||
load()
|
||||
}
|
||||
|
||||
/** Open the drawer without the trigger button (the database manager's tree
|
||||
* row menu drives it). Not named `open`: that is the drawer's own state. */
|
||||
export function openPermissions() {
|
||||
openDrawer()
|
||||
}
|
||||
|
||||
function addRole() {
|
||||
roles.push({ id: randomUUID(), name: '', tenants: [] })
|
||||
}
|
||||
|
||||
function removeRole(id: string) {
|
||||
roles = roles.filter((r) => r.id !== id)
|
||||
if (defaultRoleId === id) {
|
||||
// Deleting the default falls back to admin rather than leaving the save
|
||||
// pointing at a role that no longer exists.
|
||||
defaultRoleId = roles.find((r) => r.name === ADMIN_DATATABLE_ROLE)?.id
|
||||
}
|
||||
}
|
||||
|
||||
let hasUnsavedChanges = $derived(
|
||||
!deepEqual(saved, {
|
||||
enabled,
|
||||
roles: $state.snapshot(roles) as EditedRole[],
|
||||
defaultRoleId
|
||||
})
|
||||
)
|
||||
|
||||
// The backend validates these too; catching them here keeps a half-typed row
|
||||
// from costing a round trip to find out.
|
||||
let nameError = $derived.by(() => {
|
||||
if (!enabled) return undefined
|
||||
const names = roles.map((r) => r.name.trim())
|
||||
if (names.some((n) => !n)) return 'Every role needs a name'
|
||||
const invalid = names.find((n) => !/^[a-zA-Z0-9_-]{1,63}$/.test(n))
|
||||
if (invalid) return `Invalid role name '${invalid}': letters, digits, '_' or '-' only`
|
||||
if (new Set(names).size !== names.length) return 'Role names must be unique'
|
||||
return undefined
|
||||
})
|
||||
|
||||
function buildRequest() {
|
||||
const savedById = new Map(saved.roles.map((r) => [r.id, r.name]))
|
||||
return {
|
||||
enabled,
|
||||
roles: roles.map((r) => ({ name: r.name.trim(), tenants: $state.snapshot(r.tenants) })),
|
||||
default_role: roles.find((r) => r.id === defaultRoleId)?.name.trim() ?? ADMIN_DATATABLE_ROLE,
|
||||
renames: roles
|
||||
.filter((r) => savedById.has(r.id) && savedById.get(r.id) !== r.name.trim())
|
||||
.map((r) => ({ from: savedById.get(r.id)!, to: r.name.trim() }))
|
||||
}
|
||||
}
|
||||
|
||||
let preview = $state<{ statements: string[]; warnings: string[] } | undefined>(undefined)
|
||||
let applying = $state(false)
|
||||
|
||||
// Save is always a two-step: the SQL a change plans out is shown before it is
|
||||
// run, since creating and especially dropping roles is not undoable.
|
||||
async function requestPreview() {
|
||||
saving = true
|
||||
try {
|
||||
preview = await WorkspaceService.previewDatatablePermissions({
|
||||
workspace,
|
||||
datatableName: datatable,
|
||||
requestBody: buildRequest()
|
||||
})
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
applying = true
|
||||
try {
|
||||
await WorkspaceService.setDatatablePermissions({
|
||||
workspace,
|
||||
datatableName: datatable,
|
||||
requestBody: buildRequest()
|
||||
})
|
||||
sendUserToast('Data table permissions saved')
|
||||
preview = undefined
|
||||
await load()
|
||||
onSaved?.()
|
||||
} catch (e) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
applying = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !hideTrigger}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
iconOnly
|
||||
{disabled}
|
||||
title="Permissions: restrict who can use this data table, and as which database role"
|
||||
on:click={openDrawer}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Drawer bind:open size="900px">
|
||||
<DrawerContent
|
||||
title="Permissions — {datatable}"
|
||||
on:close={() => (open = false)}
|
||||
tooltip="Map Windmill roles onto Postgres roles, so a script that runs as a role connects as it and the database enforces what it may touch."
|
||||
>
|
||||
{#if loadError}
|
||||
<Alert type="error" title="Could not load permissions" size="xs">{loadError}</Alert>
|
||||
{:else if loading}
|
||||
<span class="text-sm text-tertiary">Loading...</span>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<Toggle
|
||||
bind:checked={enabled}
|
||||
disabled={!editable || (isFork && !enabled)}
|
||||
options={{
|
||||
right: 'Enable permissions',
|
||||
rightTooltip:
|
||||
'While off, every workspace member reaches this data table through its single connection. Turning it off again drops the roles created here, after giving their objects back to admin.'
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if !editable && ownerWorkspace}
|
||||
<Alert type="info" title="Managed from workspace {ownerWorkspace}" size="xs">
|
||||
This data table reaches a database whose permissions were turned on in workspace
|
||||
{ownerWorkspace}. Its roles and tenants are that workspace's, and its admins change them
|
||||
there.
|
||||
</Alert>
|
||||
{:else if isFork && !enabled}
|
||||
<Alert type="info" title="Permissions belong to the workspace this was forked from" size="xs">
|
||||
A fork's data table points either at that workspace's database, which is where to
|
||||
enable permissions, or at a copy this fork can drop.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if enabled}
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>
|
||||
Role
|
||||
<Tooltip>
|
||||
admin is the connection the data table already used, so it owns every existing
|
||||
object and cannot be renamed or removed. Every other role gets its own Postgres
|
||||
login, created with no privileges — grant it what it needs from admin.
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell head>
|
||||
Tenants
|
||||
<Tooltip>
|
||||
Users, groups and folders allowed to run as this role. Workspace admins can use
|
||||
every role.
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell head>
|
||||
Default
|
||||
<Tooltip>
|
||||
The role a script gets when it names none — no `-- role` annotation, no `?role=`
|
||||
in an ATTACH. Callers still have to be one of its tenants.
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell head last />
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface-tertiary">
|
||||
{#each roles as role (role.id)}
|
||||
{@const isRoot = role.name === ADMIN_DATATABLE_ROLE}
|
||||
<Row>
|
||||
<Cell first class="w-56 align-top">
|
||||
<div class="flex flex-col gap-1">
|
||||
<TextInput
|
||||
bind:value={role.name}
|
||||
inputProps={{ placeholder: 'Role name', disabled: isRoot }}
|
||||
/>
|
||||
{#if role.pg_rolename}
|
||||
<span class="text-2xs text-tertiary font-mono select-all"
|
||||
>{role.pg_rolename}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="align-top">
|
||||
<MultiSelect
|
||||
items={tenantItems.current ?? []}
|
||||
bind:value={role.tenants}
|
||||
groupBy={(item) => item.group}
|
||||
placeholder="Nobody — Add users, groups or folders"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell class="w-20 align-top">
|
||||
<div class="flex justify-center pt-2">
|
||||
<Checkbox
|
||||
checked={defaultRoleId === role.id}
|
||||
title="Use this role when a script names none"
|
||||
onChange={() => (defaultRoleId = role.id)}
|
||||
/>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last class="w-10 align-top">
|
||||
{#if !isRoot}
|
||||
<CloseButton small on:close={() => removeRole(role.id)} />
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
<Row class="!border-0">
|
||||
<Cell colspan={4} class="pt-0 pb-2">
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
btnClasses="max-w-fit"
|
||||
variant="default"
|
||||
on:click={addRole}
|
||||
>
|
||||
<Plus /> New role
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{#if nameError}
|
||||
<span class="text-xs text-red-600">{nameError}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if enabled && !hasUnsavedChanges && !loadError && !loading}
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<PgAclEditor {workspace} {datatable} target={{ kind: 'database' }} showOwner={false} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
disabled={!editable || !hasUnsavedChanges || loading || !!loadError || !!nameError}
|
||||
loading={saving}
|
||||
on:click={requestPreview}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Portalled to the body: this button is also mounted inside the database
|
||||
manager's drawer, whose own stacking context would otherwise trap the modal
|
||||
underneath the permissions drawer sitting next to it. -->
|
||||
<Portal>
|
||||
<ConfirmationModal
|
||||
open={!!preview}
|
||||
title="Apply permission changes"
|
||||
confirmationText={preview?.statements.length ? 'Run and save' : 'Save'}
|
||||
type="info"
|
||||
loading={applying}
|
||||
onConfirmed={apply}
|
||||
onCanceled={() => (preview = undefined)}
|
||||
>
|
||||
<div class="flex flex-col gap-3 min-w-0">
|
||||
{#each preview?.warnings ?? [] as warning}
|
||||
<Alert type="warning" title="Warning" size="xs">{warning}</Alert>
|
||||
{/each}
|
||||
{#if !preview?.statements.length}
|
||||
<span class="text-sm text-secondary">
|
||||
No SQL to run — only the tenants of existing roles changed.
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-sm text-secondary">
|
||||
The following 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"
|
||||
>{preview.statements.join('\n')}</pre
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
</Portal>
|
||||
@@ -51,12 +51,11 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Plus, PlugZap } from 'lucide-svelte'
|
||||
import { History, KeyRound, Plus, PlugZap, Trash2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
|
||||
import Button from '../common/button/Button.svelte'
|
||||
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
|
||||
import ResourcePicker from '../ResourcePicker.svelte'
|
||||
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
@@ -79,7 +78,7 @@
|
||||
type GetSettingsResponse,
|
||||
type TestDataTableConnectionResponse
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { resource } from 'runed'
|
||||
@@ -87,8 +86,9 @@
|
||||
import { Popover } from '../meltComponents'
|
||||
import ExploreAssetButton from '../ExploreAssetButton.svelte'
|
||||
import DataTableMigrationsButton from './DataTableMigrationsButton.svelte'
|
||||
import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { clone } from '$lib/utils'
|
||||
import { clone, onlyAlphaNumAndUnderscore } from '$lib/utils'
|
||||
import SettingsFooter from './SettingsFooter.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
|
||||
@@ -253,6 +253,10 @@
|
||||
}
|
||||
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
// The two components mount their own modal and drawer; the row menu drives them.
|
||||
let migrationsButtons = $state<Record<string, DataTableMigrationsButton | undefined>>({})
|
||||
let permissionsButtons = $state<Record<string, DataTablePermissionsButton | undefined>>({})
|
||||
|
||||
let dirtyMap = $derived.by(() => {
|
||||
const map: Record<string, boolean> = {}
|
||||
for (let i = 0; i < tempSettings.dataTables.length; i++) {
|
||||
@@ -411,9 +415,16 @@
|
||||
<Cell class="whitespace-nowrap">
|
||||
<div class="flex gap-2">
|
||||
<DataTableMigrationsButton
|
||||
bind:this={migrationsButtons[dataTable.name]}
|
||||
hideTrigger
|
||||
workspace={$workspaceStore ?? ''}
|
||||
datatable={dataTable.name}
|
||||
/>
|
||||
<DataTablePermissionsButton
|
||||
bind:this={permissionsButtons[dataTable.name]}
|
||||
hideTrigger
|
||||
workspace={$workspaceStore ?? ''}
|
||||
datatable={dataTable.name}
|
||||
disabled={!!dirtyMap[dataTable.name]}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -448,7 +459,36 @@
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="w-12">
|
||||
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
|
||||
<DropdownV2
|
||||
items={() => [
|
||||
{
|
||||
displayName: 'Migrations',
|
||||
icon: History,
|
||||
// Both act on the saved data table, which unsaved edits are not.
|
||||
disabled: !!dirtyMap[dataTable.name],
|
||||
tooltip: 'Save the settings first',
|
||||
action: () => migrationsButtons[dataTable.name]?.open()
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
icon: KeyRound,
|
||||
// The server refuses to plan a permissions change without a
|
||||
// license, so the entry says so rather than failing later.
|
||||
disabled: !!dirtyMap[dataTable.name] || !$enterpriseLicense,
|
||||
tooltip: !$enterpriseLicense
|
||||
? 'Data table permissions require an Enterprise license'
|
||||
: 'Save the settings first',
|
||||
action: () => permissionsButtons[dataTable.name]?.openPermissions()
|
||||
},
|
||||
{
|
||||
displayName: 'Remove',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => removeDataTable(dataTableIndex)
|
||||
}
|
||||
]}
|
||||
btnId={'datatable-settings-actions-' + onlyAlphaNumAndUnderscore(dataTable.name)}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user