mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-17 00:02:31 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e6748c4a | ||
|
|
7d56253e26 | ||
|
|
0de006f47d | ||
|
|
b076f0ddbe | ||
|
|
bc50760824 | ||
|
|
c927138c9e | ||
|
|
a9ec0aec3a | ||
|
|
9d348f84c7 | ||
|
|
02e47de8b4 | ||
|
|
64dffe6106 |
@@ -31,8 +31,9 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
`cargo run`; a normal build cannot start one at all.
|
||||
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
|
||||
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
|
||||
scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`.
|
||||
Read before designing anything that creates users, tokens or sessions.
|
||||
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
|
||||
that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates
|
||||
users, tokens or sessions.
|
||||
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
|
||||
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
|
||||
silently, so frontend-only instrumentation records nothing.
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT 1 AS one FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "one",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "06abbf945bee93349ff88f64906b96ea1e853ef202510281427cfa9beeff81b3"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE EXISTS (\n SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d\n WHERE d.value->'reference'->>'workspace_id' = $1\n AND d.value->'reference'->>'datatable' = $2\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "06ce02cd7ce2f5a57355153edb573c242f9ba758db66e9a5e16f30e3e1494201"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT permissioned_as, permissioned_as_email FROM v2_job\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "permissioned_as",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "permissioned_as_email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0d10e0fa5cf4033c7d93c9ed56be8209046007917f44da954eccf2188e5bff1f"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $2\n THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND ws.datatable::text LIKE '%\"reference\"%'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "297c7a40dfce729d44aa37bc7c65560517bd25e40c0752a00467829191e2eb98"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND trigger_kind = 'postgres'\n AND (trigger_config->>'postgres_resource_path' = $2\n OR trigger_config->>'postgres_resource_path' LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2a391cc1bfcd2f75b46144a394c01237e09c3060da88170f1f6e06468309d213"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Name"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "334dbcd48fb59c96c62c2705ab2d1ce716cd52417f487cc1a8dd376017b2db7d"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1\n AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4692810d2be817bbb5de9b476d68d695941bd4fb5ccef393e4da522ed479d601"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5048e21546f9710697100100e1255ab103979433bc386d7c89d0e30db12bfd57"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "538dd1779874e4003932d7f17750239c625f85e25b3364bf2edf566f518c8ee2"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "58e5cfe9eb87bda9f7de87c403861b6e7b9d35a41594681e2a92a87359e6a018"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909"
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, name, enabled, pwd FROM datatable_role",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "pwd",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $2\n ORDER BY ws.workspace_id, dt.key\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "975e404ac3a6258bb8220e122e3de094c7ab23330fdbc74d6e4ad472ddd3c820"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext('datatable_role_catalog'))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9f663180166f53d117e794f3f3a5723a0a43db163ecca7d5a63d4e74ab1d3be1"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND trigger_kind = 'postgres'\n AND (trigger_config->>'postgres_resource_path' = $2\n OR trigger_config->>'postgres_resource_path' LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a57de2bb0442a5ee8a607cd63cfcf675de175796184f620cb4b09670c8b0b19f"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.datatable->'datatables' FROM workspace_settings ws\n WHERE ws.workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b42af37fb474bea4c5419b0a46d9eadfe384013ab970ccf9c5effd1c78321b7c"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT jsonb_object_keys(value->'databases') FROM global_settings\n WHERE name = 'custom_instance_pg_databases'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jsonb_object_keys",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM usr WHERE email = $1 RETURNING username, workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c1d026c886799dabc39ce73e1fe09ccb175c7271df75d67aa9c72ad6f825a992"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n ORDER BY ws.workspace_id, dt.key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c5451ea9d9fa5146af242d1ee8c19ebd65b80e7ed9f29b9fb2e03767c2aa94ba"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM datatable_role WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c85d362fe2e652d4ac01a35bf470e80b993020a2ff5dcb5849dc570d52798587"
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id = $1\n AND dt.key <> $2\n AND NOT dt.value ? 'permissions'\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $3\n ORDER BY dt.key",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "datatable!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
|
||||
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND ws.datatable::text LIKE '%\"reference\"%'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "da5057c86607327bafc2942c218025ca9181a0c396405984d87e422e129521c1"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value->'roles' FROM global_settings WHERE name = 'custom_instance_pg_databases'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "dc8dfc37559e9b6713bde48155f48b5a2c7b8199eace1508e102b60d1ff40c04"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1\n AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e159b2ff15633f85e839ee4fe1ec2ecd11caf228ea8d0f52ad66def595644250"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = jsonb_set(\n jsonb_set(\n datatable #- ARRAY['datatables', $2, 'reference'],\n ARRAY['datatables', $2, 'database'], $3::jsonb),\n ARRAY['datatables', $2, 'forked_from'], $4::jsonb\n )\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ebaf3ed3097621da59dd201b5a4b9d1f440692f183c7c378f59e4b73f1c6e241"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id FROM workspace_settings WHERE datatable::text LIKE $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f3ee09fb17955ca8d886f446d397063c4094546a7807343b570b823796372cef"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET datatable = CASE WHEN $3::jsonb = 'null'::jsonb\n THEN datatable #- ARRAY['datatables', $2, 'permissions']\n ELSE jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3::jsonb)\n END\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f4adc9e26ebfebce18a29fb2c21bf06394cacb8a9699a608327b097e0ac1363e"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE datatable_role SET name = $2, enabled = $3, pwd = $4 WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fcb34e643b888122766e115a01394ab31ac856252aaa76c75ea27a447009c363"
|
||||
}
|
||||
Generated
-2
@@ -15470,7 +15470,6 @@ dependencies = [
|
||||
"windmill-ai",
|
||||
"windmill-alerting",
|
||||
"windmill-api-auth",
|
||||
"windmill-audit",
|
||||
"windmill-common",
|
||||
"windmill-object-store",
|
||||
]
|
||||
@@ -15653,7 +15652,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"pkcs1",
|
||||
"postgres-native-tls 0.5.3",
|
||||
"postgres-protocol",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
|
||||
@@ -624,7 +624,6 @@ wasm-bindgen-test = "^0"
|
||||
convert_case = "0.6.0"
|
||||
getrandom = "0.2"
|
||||
tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]}
|
||||
postgres-protocol = "0.6"
|
||||
rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"}
|
||||
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
|
||||
bit-vec = "=0.6.3"
|
||||
|
||||
@@ -1 +1 @@
|
||||
a9b17eb4e58f6ab67c871fac81ec441b4c0f28d1
|
||||
d252afcc80e77fcc4f9a2a346b80908c8605a6c0
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Refuse while the catalog holds anything. Each row is a live Postgres login with a password
|
||||
-- only this table carries, so dropping it would leave credentials on the cluster that Windmill can
|
||||
-- no longer disable, delete or even name — and re-applying could not recreate them, because the
|
||||
-- role names would already be taken. Cleaning them up here is not an option either: dropping a
|
||||
-- role means reassigning what it owns in *every* instance database, and a migration runs in one.
|
||||
--
|
||||
-- Delete the roles through instance settings first; that path does the cluster work.
|
||||
LOCK TABLE datatable_role IN ACCESS EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM datatable_role) THEN
|
||||
RAISE EXCEPTION 'Cannot roll back: % data table role(s) still exist as Postgres logins. Delete them in instance settings first, which drops them from the cluster.',
|
||||
(SELECT count(*) FROM datatable_role);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP TABLE IF EXISTS datatable_role;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- The instance's data table role catalog: one row per Postgres login Windmill created for data
|
||||
-- table access.
|
||||
--
|
||||
-- A table rather than a `global_settings` key, because the value is a set of live cluster
|
||||
-- credentials and that table has generic read, list, write and CLI round-trip paths that know
|
||||
-- nothing about what they are carrying. Every one of them is a way to leak the passwords or to
|
||||
-- overwrite the catalog with a copy that has none, and a row nothing generic touches has none of
|
||||
-- those. One row per role also makes two concurrent creates two inserts rather than a
|
||||
-- read-modify-write over one document.
|
||||
CREATE TABLE datatable_role (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
-- The Postgres role name, verbatim. Unique because it is the cluster's own key.
|
||||
name VARCHAR(63) NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
-- Generated by Windmill, never entered by anyone, and never leaves the server.
|
||||
pwd TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
GRANT ALL ON datatable_role TO windmill_user;
|
||||
GRANT ALL ON datatable_role TO windmill_admin;
|
||||
@@ -730,12 +730,7 @@ pub fn parse_asset_syntax(
|
||||
s: &str,
|
||||
enable_default_syntax: bool,
|
||||
) -> Option<(AssetKind, Cow<'_, str>)> {
|
||||
// `datatable` and `datatable?role=analyst` both name the default data table: the role picks
|
||||
// which Postgres login the connection is made as, not which data table is read.
|
||||
if enable_default_syntax
|
||||
&& s.strip_prefix("datatable")
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('?'))
|
||||
{
|
||||
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")));
|
||||
@@ -746,14 +741,6 @@ pub fn parse_asset_syntax(
|
||||
if *kind == AssetKind::Dbt {
|
||||
return Some((*kind, Cow::Owned(canonicalize_table_asset_path(suffix))));
|
||||
}
|
||||
// Same reasoning as above, for the explicit form. Specific to data tables: a
|
||||
// `Resource`'s `?table=` is part of what it names, and stripping it would merge two
|
||||
// different assets.
|
||||
if *kind == AssetKind::DataTable {
|
||||
if let Some((path, _role)) = suffix.split_once('?') {
|
||||
return Some((*kind, Cow::Borrowed(path)));
|
||||
}
|
||||
}
|
||||
// The suffix is kept verbatim. For S3 the path encodes the storage:
|
||||
// `s3://<storage>/<key>`, with an EMPTY storage segment for the
|
||||
// workspace default — so `s3:///key` yields `/key` (leading slash
|
||||
@@ -1705,25 +1692,6 @@ fn parse_trigger_spec(s: &str) -> Option<TriggerSpec> {
|
||||
mod pipeline_annotation_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_datatable_role_is_not_part_of_the_asset_it_names() {
|
||||
// The role picks which Postgres login the connection is made as, so two references that
|
||||
// differ only by role are the same asset and must land on one graph node.
|
||||
assert_eq!(
|
||||
parse_asset_syntax("datatable://sales?role=analytics", false),
|
||||
Some((AssetKind::DataTable, Cow::Borrowed("sales")))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_asset_syntax("datatable?role=analytics", true),
|
||||
Some((AssetKind::DataTable, Cow::Borrowed("main")))
|
||||
);
|
||||
// A resource's `?table=` is part of what it names, so it is kept.
|
||||
assert_eq!(
|
||||
parse_asset_syntax("$res:f/db/pg?table=users", false),
|
||||
Some((AssetKind::Resource, Cow::Borrowed("f/db/pg?table=users")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_path_keeps_storage_distinction() {
|
||||
// An S3 asset path is `<storage>/<key>` with an empty storage segment
|
||||
|
||||
+3
-15
@@ -4712,17 +4712,11 @@ const GIT_AUTO_PULL_LOCK_ID: i64 = 737_483_921;
|
||||
/// Poll every git-sync repository with auto-pull enabled and enqueue a pull when
|
||||
/// the tracked branch has new commits (repo → Windmill direction).
|
||||
///
|
||||
/// Runs on a single replica at a time (advisory lock) and only on
|
||||
/// Enterprise-licensed instances. Detection is `git ls-remote`; GitHub-App
|
||||
/// repositories are skipped here and sync via webhooks instead (phase 2).
|
||||
/// Runs on a single replica at a time (advisory lock). Detection is
|
||||
/// `git ls-remote`; GitHub-App repositories are skipped here and sync via
|
||||
/// webhooks instead (phase 2).
|
||||
#[cfg(feature = "private")]
|
||||
pub async fn poll_git_auto_pull(db: &Pool<Postgres>) {
|
||||
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
|
||||
|
||||
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lock_conn = match db.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -4792,12 +4786,6 @@ const GIT_CREDENTIAL_LOCK_ID: i64 = 737_483_923;
|
||||
/// sync down on its expiry date.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
async fn maintain_git_credentials(db: &Pool<Postgres>) {
|
||||
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
|
||||
|
||||
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transaction-scoped advisory lock, as for the schedule reconcile above: a
|
||||
// session lock on a pooled connection would ride back into the pool still
|
||||
// held if the sweep died before unlocking, and wedge the pass on every
|
||||
|
||||
@@ -1141,6 +1141,9 @@ impl NewToken {
|
||||
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
|
||||
/// skip it, since their scopes derive from the action being authorized, not the
|
||||
/// caller's token).
|
||||
///
|
||||
/// A token the system mints for itself with an `expiration` needs a label reserved in
|
||||
/// `windmill_common::auth::is_user_token`, or its expiry alerts its owner (docs/auth-surface.md).
|
||||
pub async fn create_token_internal(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
db: &DB,
|
||||
|
||||
@@ -800,14 +800,6 @@ async fn delete_folder(
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
|
||||
// See the same call in `delete_group`: a freed name must not stay in a tenant list.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&format!("f/{name}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let del = sqlx::query_scalar!(
|
||||
"DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1",
|
||||
name,
|
||||
|
||||
@@ -797,15 +797,6 @@ async fn delete_group(
|
||||
}
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
|
||||
// A tenant list names a principal, so a freed name must not linger in one: a later group
|
||||
// reusing it would silently inherit the data table access this one had.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&format!("g/{name}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2",
|
||||
name,
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Who may read and change a data table's grants and owners. On the Enterprise Edition: its
|
||||
//! administrators, from the workspace that governs it. Without it: nobody. Each refusal is decided
|
||||
//! before anything connects to the data table, so the fixture's database never has to exist.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn grant_select_on_public() -> Value {
|
||||
json!({
|
||||
"target": {"kind": "schema", "schema": "public"},
|
||||
"change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"],
|
||||
"scope": "all_tables"},
|
||||
"statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#]
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_acl(
|
||||
port: u16,
|
||||
w_id: &str,
|
||||
action: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<reqwest::Response> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&grant_select_on_public())
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A fork reaches the data table through a pointer: it may use it, never change what each role may
|
||||
/// touch on it — not even as an admin of the fork.
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_fork_cannot_change_access_on_the_data_table_it_points_at(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
for action in ["plan", "apply"] {
|
||||
let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_member_who_is_not_an_admin_cannot_change_access(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
for action in ["plan", "apply"] {
|
||||
let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Not even reading, and not even on a data table that is not under roles — which any member
|
||||
/// reaches, so only the edition stands between them and the instance's credentials.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn only_the_enterprise_edition_has_the_access_editor(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = datatable #- '{datatables,main,permissions}'
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let read = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/datatable_acl/main?kind=database"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
let mut responses = vec![("read", read)];
|
||||
for action in ["plan", "apply"] {
|
||||
responses.push((
|
||||
action,
|
||||
post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?,
|
||||
));
|
||||
}
|
||||
for (action, resp) in responses {
|
||||
assert_eq!(resp.status(), 400, "{action}");
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("Data table roles are a Windmill Enterprise Edition feature"),
|
||||
"{action}: {body}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
-- A data table under roles in `test-workspace`, and a fork whose entry points at it rather than
|
||||
-- carrying a copy. `test-user-2` is a non-admin of the parent and an admin of the fork: the shape
|
||||
-- the pointer exists for.
|
||||
|
||||
-- Empty registry: role provisioning grants CONNECT on every database named here, and the data
|
||||
-- table's `dt_main` is a name in workspace settings, not a database that exists.
|
||||
INSERT INTO global_settings (name, value) VALUES
|
||||
('custom_instance_pg_databases', '{"user_pwd": "pw", "databases": {}}'::jsonb)
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value;
|
||||
|
||||
INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ('role1', 'analytics', true, 'pw');
|
||||
|
||||
UPDATE workspace_settings SET datatable = '{
|
||||
"datatables": {
|
||||
"main": {
|
||||
"database": {"resource_type": "instance", "resource_path": "dt_main"},
|
||||
"permissions": {
|
||||
"default_role": "role1",
|
||||
"roles": {
|
||||
"admin": {"tenants": []},
|
||||
"role1": {"tenants": ["u/test-user-2", "g/analysts", "f/finance"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}'::jsonb WHERE workspace_id = 'test-workspace';
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace', 'analysts', 'Analysts', '{}');
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES
|
||||
('test-workspace', 'finance', 'finance', '{}', '{}');
|
||||
|
||||
INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES
|
||||
('wm-fork-dt', 'fork of test-workspace', 'test2@windmill.dev', 'test-workspace');
|
||||
INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('wm-fork-dt', 'cloud', 'test-key');
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('wm-fork-dt', 'all', 'All users', '{}');
|
||||
INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
|
||||
('wm-fork-dt', 'test2@windmill.dev', 'test-user-2', true, 'Admin');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('wm-fork-dt', '{
|
||||
"datatables": {
|
||||
"main": {"reference": {"workspace_id": "test-workspace", "datatable": "main"}}
|
||||
}
|
||||
}'::jsonb);
|
||||
@@ -179,9 +179,11 @@ async fn test_trigger_token_labels_still_creatable(db: Pool<Postgres>) -> anyhow
|
||||
"http-test-user-2-cd34",
|
||||
"email-test-user-2-ef56",
|
||||
"my-ci-token",
|
||||
// Minted client-side by the editor (every TypeScript editor load) and the debugger.
|
||||
// Minted client-side by the editor (every TypeScript editor load), the debugger and
|
||||
// the object-storage "Test from a worker" button.
|
||||
"Ephemeral lsp token",
|
||||
"debugger-token",
|
||||
"ephemeral-test-connection: s3_bucket",
|
||||
] {
|
||||
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
|
||||
assert_eq!(
|
||||
|
||||
@@ -11,7 +11,7 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
enterprise = ["license"]
|
||||
private = ["windmill-common/private", "windmill-audit/private"]
|
||||
private = ["windmill-common/private"]
|
||||
parquet = ["windmill-common/parquet", "windmill-object-store/parquet"]
|
||||
license = ["dep:rsa"]
|
||||
|
||||
@@ -19,7 +19,6 @@ license = ["dep:rsa"]
|
||||
windmill-ai = { workspace = true, default-features = false }
|
||||
windmill-alerting.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-audit.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
axum.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the data table role catalog endpoints come from: the enterprise implementation, or a
|
||||
//! refusal. Roles are an Enterprise Edition feature; see `windmill_common::datatable_roles_oss`.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_roles_ee::{
|
||||
create_datatable_role, delete_datatable_role, list_datatable_roles, update_datatable_role,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
// The routes stay registered so the API has one shape; each answers after authentication, before
|
||||
// anything is read.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{
|
||||
datatable_roles_oss::datatable_roles_unavailable as unavailable, error::Result,
|
||||
};
|
||||
|
||||
pub(crate) async fn list_datatable_roles(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn create_datatable_role(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_datatable_role(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_datatable_role(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,6 @@ mod audit_logs_s3;
|
||||
mod audit_logs_s3_backfill;
|
||||
#[cfg(feature = "parquet")]
|
||||
mod background_task;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod datatable_roles_ee;
|
||||
mod datatable_roles_oss;
|
||||
#[cfg(feature = "private")]
|
||||
mod ee;
|
||||
pub mod ee_oss;
|
||||
@@ -60,7 +57,7 @@ use windmill_common::{
|
||||
global_settings::{
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
|
||||
@@ -154,28 +151,10 @@ pub fn global_service() -> Router {
|
||||
"/list_custom_instance_pg_databases",
|
||||
post(list_custom_instance_pg_databases),
|
||||
)
|
||||
.route(
|
||||
"/datatable_roles",
|
||||
get(datatable_roles_oss::list_datatable_roles)
|
||||
.post(datatable_roles_oss::create_datatable_role),
|
||||
)
|
||||
.route(
|
||||
"/datatable_roles/{id}",
|
||||
post(datatable_roles_oss::update_datatable_role)
|
||||
.delete(datatable_roles_oss::delete_datatable_role),
|
||||
)
|
||||
.route(
|
||||
"/refresh_custom_instance_user_pwd",
|
||||
post(refresh_custom_instance_user_pwd),
|
||||
)
|
||||
.route(
|
||||
"/external_instance_pg/status",
|
||||
get(get_external_instance_pg_status),
|
||||
)
|
||||
.route(
|
||||
"/external_instance_pg/setup",
|
||||
post(setup_external_instance_pg),
|
||||
)
|
||||
.route(
|
||||
"/setup_custom_instance_pg_database/{name}",
|
||||
post(setup_custom_instance_pg_database),
|
||||
@@ -946,13 +925,6 @@ async fn run_setting_pre_write_hook(
|
||||
value: &serde_json::Value,
|
||||
) -> error::Result<()> {
|
||||
match key {
|
||||
EXTERNAL_INSTANCE_PG_SETTING => {
|
||||
windmill_common::external_instance_pg::check_external_instance_pg_write(
|
||||
db,
|
||||
Some(value),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// The instance AI config is written as an untyped blob through this generic
|
||||
// endpoint, so it never passes the typed check the workspace handler applies.
|
||||
// Rates that reach a cost total unbounded would make it negative or infinite.
|
||||
@@ -1304,14 +1276,6 @@ async fn set_instance_config(
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
}
|
||||
if settings_diff
|
||||
.deletes
|
||||
.iter()
|
||||
.any(|k| k == EXTERNAL_INSTANCE_PG_SETTING)
|
||||
{
|
||||
windmill_common::external_instance_pg::check_external_instance_pg_write(&db, None)
|
||||
.await?;
|
||||
}
|
||||
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
@@ -1766,54 +1730,6 @@ async fn refresh_custom_instance_user_pwd(
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn get_external_instance_pg_status(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<windmill_common::external_instance_pg::ExternalInstancePgStatus> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
Ok(Json(
|
||||
windmill_common::external_instance_pg::external_instance_pg_status(&db).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetupExternalInstancePgBody {
|
||||
#[serde(default)]
|
||||
rotate_passwords: bool,
|
||||
}
|
||||
|
||||
async fn setup_external_instance_pg(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Json(body): Json<SetupExternalInstancePgBody>,
|
||||
) -> JsonResult<windmill_common::external_instance_pg::ExternalInstancePgSetupReport> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
let report = windmill_common::external_instance_pg::setup_external_instance_pg_unchecked(
|
||||
&db,
|
||||
body.rotate_passwords,
|
||||
)
|
||||
.await?;
|
||||
let rotated = body.rotate_passwords.to_string();
|
||||
let success = report.success.to_string();
|
||||
windmill_audit::audit_oss::audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.setup_external_instance_pg",
|
||||
windmill_audit::ActionKind::Update,
|
||||
"global",
|
||||
Some(&authed.email),
|
||||
Some(
|
||||
[
|
||||
("rotate_passwords", rotated.as_str()),
|
||||
("success", success.as_str()),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetupCustomInstanceDbBody {
|
||||
tag: Option<String>,
|
||||
|
||||
@@ -1703,25 +1703,14 @@ async fn delete_user(
|
||||
.await?;
|
||||
windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?;
|
||||
|
||||
let memberships = sqlx::query!(
|
||||
"DELETE FROM usr WHERE email = $1 RETURNING username, workspace_id",
|
||||
let usernames = sqlx::query_scalar!(
|
||||
"DELETE FROM usr WHERE email = $1 RETURNING username",
|
||||
&email_to_delete
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for row in memberships {
|
||||
let username = row.username;
|
||||
// A tenant list names a principal of its workspace, so the name has to be freed in every
|
||||
// workspace this account belonged to: a later account taking the username would otherwise
|
||||
// inherit the data table access it had.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&row.workspace_id,
|
||||
&format!("u/{username}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for username in usernames {
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -2467,15 +2456,6 @@ pub async fn delete_workspace_user_internal(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
authed: Option<&ApiAuthed>, // None for system operations
|
||||
) -> Result<()> {
|
||||
// Same reasoning as the `extra_perms` sweep below: a freed username must not stay named
|
||||
// anywhere that grants access, tenant lists included.
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
tx,
|
||||
w_id,
|
||||
&format!("u/{username_to_delete}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- Clean up extra_perms referencing this user ----
|
||||
let extra_perms_tables = [
|
||||
"script",
|
||||
@@ -3902,8 +3882,8 @@ async fn update_token_label(
|
||||
Path(token_prefix): Path<String>,
|
||||
Json(req): Json<UpdateTokenLabelRequest>,
|
||||
) -> Result<String> {
|
||||
// The new label must not collide with a system-token namespace (`session`,
|
||||
// `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are
|
||||
// The new label must not collide with a system-token namespace (see
|
||||
// `windmill_common::auth::is_user_token`): those labels are
|
||||
// load-bearing, and a user-set collision would orphan the token — hidden
|
||||
// from the UI (`isUserToken`) and rejected by the editability guard below —
|
||||
// while it still authenticates. (`is_user_token(None)` is true, so clearing
|
||||
@@ -3942,6 +3922,9 @@ async fn update_token_label(
|
||||
AND lower(label) NOT LIKE 'ephemeral%'
|
||||
AND label <> 'debugger-token'
|
||||
AND label NOT LIKE 'mcp-oauth-%'
|
||||
AND NOT starts_with(label, 'embed_app:')
|
||||
AND NOT starts_with(label, 'sdk_app:')
|
||||
AND NOT starts_with(label, 'impersonation:')
|
||||
))
|
||||
RETURNING token_prefix",
|
||||
req.label.as_deref(),
|
||||
@@ -3982,12 +3965,6 @@ async fn leave_workspace(
|
||||
) -> Result<String> {
|
||||
forbid_job_token_account_destruction(&authed)?;
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_common::workspaces::remove_datatable_tenant_in_workspace(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&format!("u/{}", authed.username),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
|
||||
&w_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the ACL planner comes from: the enterprise one, or a refusal.
|
||||
//!
|
||||
//! Data table roles are an Enterprise Edition feature, and so is everything here — reading who
|
||||
//! owns what included. `private` alone is not that edition — community builds carry it — so the
|
||||
//! planner is behind `enterprise` as well.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_acl_ee::plan_statements;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) fn ensure_datatable_acl_available() -> windmill_common::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
use {
|
||||
crate::datatable_acl::{AclChange, AclPlan, AclTarget, CatalogFacts},
|
||||
windmill_common::{datatable_roles_oss::datatable_roles_unavailable, error::Result},
|
||||
};
|
||||
|
||||
/// Checked first by every ACL route, before anything is read or connected to.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn ensure_datatable_acl_available() -> Result<()> {
|
||||
Err(datatable_roles_unavailable())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn plan_statements(
|
||||
_target: &AclTarget,
|
||||
_change: &AclChange,
|
||||
_dbname: &str,
|
||||
_pg_role: &str,
|
||||
_facts: &CatalogFacts,
|
||||
) -> Result<AclPlan> {
|
||||
Err(datatable_roles_unavailable())
|
||||
}
|
||||
@@ -30,7 +30,6 @@ use windmill_api_auth::{require_super_admin, ApiAuthed};
|
||||
use windmill_api_jobs::run_wait_result_internal;
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
|
||||
use windmill_common::jobs::{JobPayload, RawCode};
|
||||
@@ -39,11 +38,7 @@ use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, Debounci
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::worker::SqlAnnotations;
|
||||
use windmill_common::workspaces::{
|
||||
ensure_can_use_datatable_role, ensure_datatable_admin_access,
|
||||
get_datatable_resource_from_db_unchecked, resolve_governing_datatable, DatatableAccess,
|
||||
};
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||||
use windmill_common::{PgDatabase, DB};
|
||||
use windmill_git_sync::{
|
||||
handle_deployment_metadata, handle_deployment_metadata_batch, DeployedObject,
|
||||
@@ -91,42 +86,6 @@ pub(crate) fn routes() -> Router {
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse a migration whose role this caller may not use, before a job is pushed or a version
|
||||
/// recorded.
|
||||
///
|
||||
/// A migration that declares `-- role <name>` runs as that role, so the caller has to be one of its
|
||||
/// tenants. One that declares none runs as `admin` and reaches every object in the database
|
||||
/// whatever the roles grant, so it is for the admins of the workspace that governs the data table
|
||||
/// — a fork can run a migration under a role it holds, never a migration under `admin`.
|
||||
///
|
||||
/// The executor re-checks the role when it resolves the connection, so this is not the boundary. It
|
||||
/// is what makes the refusal legible: which migration, and which role.
|
||||
async fn ensure_migration_role_allowed(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
sql: &str,
|
||||
timestamp: i64,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
let context = format!("Migration {timestamp} ({name})");
|
||||
let access = DatatableAccess::Authed(authed.to_authed_ref());
|
||||
match SqlAnnotations::datatable_role(sql)? {
|
||||
Some(role) => {
|
||||
ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, &context)
|
||||
.await
|
||||
}
|
||||
None => ensure_datatable_admin_access(db, w_id, datatable_name, &access)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::NotAuthorized(format!(
|
||||
"{context} declares no role, so it would run as admin. {e}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppliedMigration {
|
||||
version: i64,
|
||||
@@ -169,14 +128,7 @@ async fn datatable_database_arg(
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?;
|
||||
|
||||
// `?role=admin` rather than a bare reference, so a migration that declares no `-- role` runs
|
||||
// as the connection that owns the schema instead of falling through to the data table's
|
||||
// default role — which is what `ensure_migration_role_allowed` gated it as, and which is the
|
||||
// only role a DDL statement can be expected to succeed under. A migration that does declare a
|
||||
// role overrides this: the annotation wins over the reference.
|
||||
Ok(to_raw_value(&format!(
|
||||
"datatable://{datatable_name}?role={ADMIN_DATATABLE_ROLE}"
|
||||
)))
|
||||
Ok(to_raw_value(&format!("datatable://{datatable_name}")))
|
||||
}
|
||||
|
||||
/// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as
|
||||
@@ -432,11 +384,6 @@ async fn run_datatable_migrations(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Query(query): Query<RunDatatableMigrationsQuery>,
|
||||
) -> JsonResult<RunDatatableMigrationsResult> {
|
||||
// Before the admin connection is opened at all: the bookkeeping below is created and read
|
||||
// through it, so a caller no role covers must be refused here rather than after the fact.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
@@ -493,16 +440,6 @@ async fn run_datatable_migrations(
|
||||
if applied_versions.contains(&m.timestamp) {
|
||||
continue;
|
||||
}
|
||||
ensure_migration_role_allowed(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&m.code_up,
|
||||
m.timestamp,
|
||||
&m.name,
|
||||
)
|
||||
.await?;
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -569,11 +506,6 @@ async fn rollback_datatable_migrations(
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Query(query): Query<RollbackDatatableMigrationsQuery>,
|
||||
) -> JsonResult<RollbackDatatableMigrationsResult> {
|
||||
// Before the admin connection is opened at all: the bookkeeping below is created and read
|
||||
// through it, so a caller no role covers must be refused here rather than after the fact.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
@@ -656,17 +588,6 @@ async fn rollback_datatable_migrations(
|
||||
))
|
||||
})?;
|
||||
|
||||
ensure_migration_role_allowed(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&authed,
|
||||
&code_down,
|
||||
version,
|
||||
&definition.name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?;
|
||||
run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &code_down)
|
||||
.await
|
||||
@@ -827,15 +748,10 @@ async fn read_applied_datatable_versions(
|
||||
|
||||
/// List a data table's migrations annotated with whether each has been applied.
|
||||
async fn datatable_migrations_status(
|
||||
authed: ApiAuthed,
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<DatatableMigrationsStatusResult> {
|
||||
// Reads `_wm_migrations` through the data table's admin connection, so it answers to the same
|
||||
// question as running one: may you reach this data table at all.
|
||||
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
|
||||
.await?;
|
||||
|
||||
let enabled = datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
if !enabled {
|
||||
return Ok(Json(DatatableMigrationsStatusResult {
|
||||
@@ -1515,15 +1431,6 @@ async fn generate_initial_datatable_migration(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> JsonResult<DatatableMigration> {
|
||||
// Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so
|
||||
// it answers to the workspace that governs it rather than to whoever is asking.
|
||||
ensure_datatable_admin_access(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&DatatableAccess::Authed(authed.to_authed_ref()),
|
||||
)
|
||||
.await?;
|
||||
validate_datatable_path_segment(&datatable_name)?;
|
||||
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
|
||||
|
||||
@@ -1694,21 +1601,9 @@ pub(crate) struct DatatableRename {
|
||||
pub(crate) to: String,
|
||||
}
|
||||
|
||||
/// The database whose `_wm_migrations` a rename or delete of `datatable` in `w_id` should touch —
|
||||
/// `None` when that is somebody else's.
|
||||
///
|
||||
/// A fork's entry points at the workspace that governs the data table, so renaming or removing it
|
||||
/// changes what the fork calls the data table and nothing more. Following the pointer here would
|
||||
/// let a fork admin relabel or wipe the *governing* workspace's migration bookkeeping through
|
||||
/// their own settings form, and the parent would then re-run every migration from zero.
|
||||
async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result<Option<PgDatabase>> {
|
||||
let governing = resolve_governing_datatable(db, w_id, datatable).await?;
|
||||
if governing.workspace_id != w_id {
|
||||
return Ok(None);
|
||||
}
|
||||
async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result<PgDatabase> {
|
||||
let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable).await?;
|
||||
serde_json::from_value(db_resource)
|
||||
.map(Some)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))
|
||||
}
|
||||
|
||||
@@ -1726,9 +1621,7 @@ fn ignore_missing_wm_migrations(e: tokio_postgres::Error) -> Result<()> {
|
||||
|
||||
/// Drop a data table's rows from its own database's `_wm_migrations`.
|
||||
async fn remote_forget_datatable_migrations(db: &DB, w_id: &str, datatable: &str) -> Result<()> {
|
||||
let Some(pg_db) = resolve_datatable_pg(db, w_id, datatable).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let pg_db = resolve_datatable_pg(db, w_id, datatable).await?;
|
||||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
@@ -1753,9 +1646,7 @@ async fn remote_rename_datatable_migrations(
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<()> {
|
||||
let Some(pg_db) = resolve_datatable_pg(db, w_id, resolve_by).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let pg_db = resolve_datatable_pg(db, w_id, resolve_by).await?;
|
||||
let (client, connection) = pg_db.connect(Some(db)).await?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Who may connect to a data table as which role.
|
||||
//!
|
||||
//! The decision lives on the data table entry of the workspace that governs it, which is not
|
||||
//! necessarily the workspace asking: a fork's entry points at its parent's, and everything here
|
||||
//! resolves through that pointer first. Nothing in this module runs SQL against the data table —
|
||||
//! a save is tenant lists and a default, and the Postgres roles themselves are the instance
|
||||
//! catalog's business.
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::error::Result;
|
||||
use windmill_common::workspaces::GoverningDatatable;
|
||||
use windmill_common::DB;
|
||||
|
||||
use crate::datatable_permissions_oss as roles;
|
||||
|
||||
pub(crate) fn routes() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/datatable_permissions/{datatable_name}",
|
||||
get(roles::get_datatable_permissions).post(roles::set_datatable_permissions),
|
||||
)
|
||||
.route(
|
||||
"/datatable_usable_roles/{datatable_name}",
|
||||
get(roles::list_usable_datatable_roles),
|
||||
)
|
||||
}
|
||||
|
||||
/// Administering a data table — its permissions, its migrations that declare no role, its exports
|
||||
/// — is for the admins of the workspace that governs it. A fork can use the data table; it never
|
||||
/// administers it.
|
||||
// The gate for whatever administers a data table under roles, which the routes of this module alone
|
||||
// do not always reach.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn ensure_governs_datatable(
|
||||
db: &DB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
governing: &GoverningDatatable,
|
||||
) -> Result<()> {
|
||||
roles::ensure_governs_datatable(db, authed, w_id, governing).await
|
||||
}
|
||||
|
||||
/// Refuse a caller that no tenant of this data table covers.
|
||||
///
|
||||
/// The bookkeeping endpoints below open the data table's `admin` connection to read or create
|
||||
/// `_wm_migrations` before they know which migration will run — so without this, someone covered
|
||||
/// by no role at all can still force admin-backed reads and writes on a database they may not
|
||||
/// touch. It asks only "may you reach this data table as anything"; which role a given migration
|
||||
/// runs as is still decided per migration, and by the executor after that.
|
||||
pub(crate) async fn ensure_reaches_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
roles::ensure_reaches_datatable(db, w_id, datatable_name, authed).await
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the data table permissions endpoints and their gates come from: the enterprise
|
||||
//! implementation, or a refusal. Roles are an Enterprise Edition feature; see
|
||||
//! `windmill_common::datatable_roles_oss`.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_permissions_ee::{
|
||||
ensure_governs_datatable, ensure_reaches_datatable, get_datatable_permissions,
|
||||
list_usable_datatable_roles, set_datatable_permissions, usable_datatable_roles,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{
|
||||
datatable_roles_oss::datatable_roles_unavailable as unavailable,
|
||||
error::Result,
|
||||
workspaces::{resolve_governing_datatable, GoverningDatatable},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// Nobody administers a data table's roles without them.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn ensure_governs_datatable(
|
||||
_db: &DB,
|
||||
_authed: &ApiAuthed,
|
||||
_w_id: &str,
|
||||
_governing: &GoverningDatatable,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// A data table not under roles is reached as it was before roles existed. One under roles is
|
||||
/// refused: no role of it can be connected as.
|
||||
pub(crate) async fn ensure_reaches_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
_authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
let governing = resolve_governing_datatable(db, w_id, datatable_name).await?;
|
||||
if governing.datatable.permissions.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
// The routes stay registered so the API has one shape; each answers after authentication,
|
||||
// before anything is read.
|
||||
|
||||
pub(crate) async fn get_datatable_permissions(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_datatable_permissions(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result<String> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) struct UsableDatatableRoles {
|
||||
pub(crate) permissioned: bool,
|
||||
pub(crate) roles: Vec<String>,
|
||||
pub(crate) default_role: String,
|
||||
}
|
||||
|
||||
/// A data table not under roles is used as `admin`, as before roles existed. One under roles
|
||||
/// is refused: no role of it can be connected as.
|
||||
pub(crate) async fn usable_datatable_roles(
|
||||
_db: &DB,
|
||||
_authed: &ApiAuthed,
|
||||
_w_id: &str,
|
||||
governing: &GoverningDatatable,
|
||||
) -> Result<UsableDatatableRoles> {
|
||||
if governing.datatable.permissions.is_some() {
|
||||
return Err(unavailable());
|
||||
}
|
||||
Ok(UsableDatatableRoles {
|
||||
permissioned: false,
|
||||
roles: vec![],
|
||||
default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
#[cfg(feature = "parquet")]
|
||||
pub mod ai_session_backups;
|
||||
pub mod data_metrics;
|
||||
pub mod datatable_acl;
|
||||
pub mod datatable_acl_oss;
|
||||
pub mod datatable_migrations;
|
||||
pub mod datatable_permissions;
|
||||
pub mod datatable_permissions_oss;
|
||||
pub mod deployment_requests;
|
||||
pub mod workspaces;
|
||||
pub mod workspaces_extra;
|
||||
@@ -13,9 +9,3 @@ pub mod workspaces_oss;
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
pub mod workspaces_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_acl_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_permissions_ee;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -492,30 +492,6 @@ pub(crate) async fn change_workspace_id(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A fork's data table entry names the workspace that governs it by id, so the rename has to
|
||||
// follow there too — anywhere, not just in the reparented children: a detached workspace can
|
||||
// point at this one without being its fork. Left behind, the pointer resolves to the archived
|
||||
// shell and every job through it stops.
|
||||
info!("Re-pointing data table references to the new workspace id");
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_settings ws
|
||||
SET datatable = (
|
||||
SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(
|
||||
dt.key,
|
||||
CASE WHEN dt.value->'reference'->>'workspace_id' = $2
|
||||
THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))
|
||||
ELSE dt.value END
|
||||
))
|
||||
FROM jsonb_each(ws.datatable->'datatables') dt
|
||||
)
|
||||
WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
AND ws.datatable::text LIKE '%"reference"%'"#,
|
||||
&rw.new_id,
|
||||
&old_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_protection_rule table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
@@ -995,22 +971,6 @@ pub(crate) async fn delete_workspace(
|
||||
// but the destructive cleanup itself runs only after the commit below: a delete that
|
||||
// fails mid-way must never leave a live workspace with its fork data destroyed and no
|
||||
// registry row to retry from. Read-only: nothing is dropped here.
|
||||
// Read before the delete: another workspace's data table entry can point at one of this
|
||||
// workspace's, and deleting the workspace it names leaves that pointer resolving to nothing.
|
||||
// Nothing sweeps them — turning them back into copies would hand each fork the database
|
||||
// outright — so the deleter is told which data tables they just stranded.
|
||||
let stranded_pointers = sqlx::query!(
|
||||
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!"
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
|
||||
WHERE dt.value->'reference'->>'workspace_id' = $1
|
||||
ORDER BY ws.workspace_id, dt.key"#,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let fork_ducklake_cleanups = prepare_fork_ducklake_cleanups(&db, &w_id, None)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -1329,23 +1289,7 @@ pub(crate) async fn delete_workspace(
|
||||
tracing::warn!("failed to broadcast fork lineage change: {e:#}");
|
||||
}
|
||||
|
||||
if stranded_pointers.is_empty() {
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
} else {
|
||||
let stranded = stranded_pointers
|
||||
.iter()
|
||||
.map(|r| format!("{}/{}", r.workspace_id, r.datatable))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Ok(format!(
|
||||
concat!(
|
||||
"Deleted workspace {}. These data tables were governed by it and no longer ",
|
||||
"resolve: {}. Their databases still exist; a superadmin can point them at ",
|
||||
"another workspace's data table."
|
||||
),
|
||||
&w_id, stranded
|
||||
))
|
||||
}
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1399,20 +1343,15 @@ pub async fn drop_forked_datatable_databases(
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
for dt_name in &req.datatable_names {
|
||||
// Only a clone is droppable, and a clone is terminal by construction: a kept data table is
|
||||
// a pointer at the parent's database, which this fork does not own.
|
||||
let database = match datatables.get(dt_name) {
|
||||
Some(dt) if dt.forked_from.is_some() => match dt.database.as_ref() {
|
||||
Some(database) => database,
|
||||
None => continue,
|
||||
},
|
||||
let dt = match datatables.get(dt_name) {
|
||||
Some(dt) if dt.forked_from.is_some() => dt,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if database.resource_type
|
||||
if dt.database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
||||
{
|
||||
let db_to_drop = &database.resource_path;
|
||||
let db_to_drop = &dt.database.resource_path;
|
||||
if !db_to_drop.starts_with("wm_fork_") {
|
||||
errors.push(format!(
|
||||
"Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'",
|
||||
|
||||
@@ -1572,43 +1572,6 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/settings/external_instance_pg/status:
|
||||
get:
|
||||
summary: Returns whether the external instance cluster is configured and how its last setup went
|
||||
operationId: getExternalInstancePgStatus
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: external instance cluster status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExternalInstancePgStatus"
|
||||
|
||||
/settings/external_instance_pg/setup:
|
||||
post:
|
||||
summary: Sets up the external instance cluster with its saved admin login, optionally rotating the passwords Windmill manages on it (enterprise edition only)
|
||||
operationId: setupExternalInstancePg
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
rotate_passwords:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: the setup report, also stored as the last setup
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExternalInstancePgSetupReport"
|
||||
|
||||
/settings/list_custom_instance_pg_databases:
|
||||
post:
|
||||
summary: Returns the set-up statuses of custom instance pg databases
|
||||
@@ -1625,89 +1588,6 @@ paths:
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/CustomInstanceDb"
|
||||
|
||||
/settings/datatable_roles:
|
||||
get:
|
||||
summary: list the instance's data table roles
|
||||
operationId: listInstanceDatatableRoles
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: the instance role catalog
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
post:
|
||||
summary: create a data table role on the instance's Postgres cluster
|
||||
operationId: createInstanceDatatableRole
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the created role
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
|
||||
/settings/datatable_roles/{id}:
|
||||
post:
|
||||
summary: rename a data table role or turn its login on and off
|
||||
operationId: updateInstanceDatatableRole
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: the updated role
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
delete:
|
||||
summary: drop a data table role from the cluster and from every workspace that named it
|
||||
operationId: deleteInstanceDatatableRole
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: deleted
|
||||
|
||||
/settings/setup_custom_instance_pg_database/{name}:
|
||||
post:
|
||||
summary: Runs CREATE DATABASE on the Windmill Postgres and grants access to the custom_instance_user
|
||||
@@ -5261,7 +5141,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [name, resource_type, resource_path, permissioned]
|
||||
required: [name, resource_type, resource_path]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
@@ -5270,188 +5150,6 @@ paths:
|
||||
enum: [postgres, instance]
|
||||
resource_path:
|
||||
type: string
|
||||
governing_workspace_id:
|
||||
type: string
|
||||
permissioned:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}:
|
||||
get:
|
||||
summary: get who may connect to a data table as which role
|
||||
operationId: getDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the data table's roles and their tenants
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DatatablePermissions"
|
||||
post:
|
||||
summary: set who may connect to a data table as which role
|
||||
operationId: setDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [permissioned]
|
||||
properties:
|
||||
permissioned:
|
||||
type: boolean
|
||||
default_role:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DatatableRoleTenants"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}:
|
||||
get:
|
||||
summary: read the owner and grants of an instance data table's database, schema or table
|
||||
operationId: getDatatableAcl
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: kind
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [database, schema, table]
|
||||
- name: schema
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: table
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: owner and grants
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DatatableAclInfo"
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan:
|
||||
post:
|
||||
summary: preview the SQL an ownership or grant change would run (data table administrators only)
|
||||
operationId: planDatatableAcl
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AclChangeRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: statements that would run, in a single transaction
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AclPlan"
|
||||
|
||||
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply:
|
||||
post:
|
||||
summary: run an ownership or grant change exactly as planned (data table administrators only)
|
||||
operationId: applyDatatableAcl
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AclChangeRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: change applied
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
|
||||
get:
|
||||
summary: list the data table roles the caller may connect as
|
||||
operationId: listUsableDatatableRoles
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: usable roles
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [permissioned, roles, default_role]
|
||||
properties:
|
||||
permissioned:
|
||||
type: boolean
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/list_datatable_schemas:
|
||||
get:
|
||||
@@ -5525,21 +5223,6 @@ paths:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: query
|
||||
description: list only this data table; each listed data table opens a connection to its database
|
||||
schema:
|
||||
type: string
|
||||
- name: role_for
|
||||
in: query
|
||||
description: the data table `role` applies to; every other one is listed as its default role
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
description: the role to list `role_for` as; refused, in that entry's `error`, if the caller may not use it
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: table metadata of all datatables
|
||||
@@ -5573,11 +5256,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
description: the data table role to read the table as; defaults to the data table's default role
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schema of one datatable table
|
||||
@@ -5653,22 +5331,7 @@ paths:
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
stranded_references:
|
||||
description: >-
|
||||
Data tables in other workspaces that were governed by one this save deleted
|
||||
and no longer resolve.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/workspaces/run_datatable_migrations/{datatable_name}:
|
||||
post:
|
||||
@@ -33658,353 +33321,6 @@ components:
|
||||
- ducklake
|
||||
- datatable
|
||||
|
||||
ExternalInstancePgSetupStep:
|
||||
type: object
|
||||
required: [name, status, message]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, warning, error]
|
||||
message:
|
||||
type: string
|
||||
|
||||
ExternalInstancePgSetupReport:
|
||||
type: object
|
||||
required: [success, finished_at, steps]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: no step failed; warnings leave it true
|
||||
finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
steps:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ExternalInstancePgSetupStep"
|
||||
|
||||
ExternalInstancePgStatus:
|
||||
type: object
|
||||
required: [configured, database_count]
|
||||
properties:
|
||||
configured:
|
||||
type: boolean
|
||||
database_count:
|
||||
type: integer
|
||||
last_setup:
|
||||
$ref: "#/components/schemas/ExternalInstancePgSetupReport"
|
||||
|
||||
InstanceDatatableRole:
|
||||
type: object
|
||||
required: [id, name, enabled]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
|
||||
DatatableRoleTenants:
|
||||
type: object
|
||||
required: [id, tenants]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
tenants:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
DatatablePermissions:
|
||||
type: object
|
||||
required: [supported, permissioned, default_role, roles, editable, available_roles]
|
||||
properties:
|
||||
supported:
|
||||
type: boolean
|
||||
description: >-
|
||||
Whether this data table can be put under roles at all. Only one backed by the
|
||||
instance database can: a role is a login on that cluster.
|
||||
permissioned:
|
||||
type: boolean
|
||||
default_role:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DatatableRoleTenants"
|
||||
governing_workspace_id:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
available_roles:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceDatatableRole"
|
||||
ungoverned_reachers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
|
||||
AclTarget:
|
||||
description: what access is read or changed on
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AclTargetDatabase"
|
||||
- $ref: "#/components/schemas/AclTargetSchema"
|
||||
- $ref: "#/components/schemas/AclTargetTable"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
database: "#/components/schemas/AclTargetDatabase"
|
||||
schema: "#/components/schemas/AclTargetSchema"
|
||||
table: "#/components/schemas/AclTargetTable"
|
||||
|
||||
AclTargetDatabase:
|
||||
type: object
|
||||
required: [kind]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [database]
|
||||
|
||||
AclTargetSchema:
|
||||
type: object
|
||||
required: [kind, schema]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [schema]
|
||||
schema:
|
||||
type: string
|
||||
|
||||
AclTargetTable:
|
||||
type: object
|
||||
required: [kind, schema, table]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [table]
|
||||
schema:
|
||||
type: string
|
||||
table:
|
||||
type: string
|
||||
|
||||
AclChange:
|
||||
description: one change to plan or apply
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AclChangeSetOwner"
|
||||
- $ref: "#/components/schemas/AclChangeGrant"
|
||||
- $ref: "#/components/schemas/AclChangeRevoke"
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
set_owner: "#/components/schemas/AclChangeSetOwner"
|
||||
grant: "#/components/schemas/AclChangeGrant"
|
||||
revoke: "#/components/schemas/AclChangeRevoke"
|
||||
|
||||
AclChangeSetOwner:
|
||||
type: object
|
||||
description: >-
|
||||
hands the target to role — for a schema, with everything already in it but an extension's
|
||||
members, which stay with the extension
|
||||
required: [type, role]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [set_owner]
|
||||
role:
|
||||
type: string
|
||||
description: a data table role of the instance, or admin
|
||||
|
||||
AclChangeGrant:
|
||||
type: object
|
||||
required: [type, role, privileges, scope]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [grant]
|
||||
role:
|
||||
type: string
|
||||
description: a data table role of the instance, or admin
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
scope:
|
||||
$ref: "#/components/schemas/AclGrantScope"
|
||||
|
||||
AclChangeRevoke:
|
||||
type: object
|
||||
required: [type, role, privileges, scope]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [revoke]
|
||||
role:
|
||||
type: string
|
||||
description: a data table role of the instance, other than admin
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
scope:
|
||||
$ref: "#/components/schemas/AclGrantScope"
|
||||
objects:
|
||||
type: array
|
||||
description: >-
|
||||
objects inside the target the revoke covers, empty for the target itself. Only with the
|
||||
target scope; a revoke on all objects of a kind is refused, since it cannot say which
|
||||
grants it takes back.
|
||||
items:
|
||||
$ref: "#/components/schemas/AclObject"
|
||||
|
||||
AclGrantScope:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
target,
|
||||
all_tables,
|
||||
all_sequences,
|
||||
all_functions,
|
||||
future_tables,
|
||||
future_sequences,
|
||||
future_functions,
|
||||
]
|
||||
|
||||
AclChangeRequest:
|
||||
type: object
|
||||
required: [target, change]
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/AclTarget"
|
||||
change:
|
||||
$ref: "#/components/schemas/AclChange"
|
||||
statements:
|
||||
type: array
|
||||
description: >-
|
||||
The statements the plan showed. Required to apply, which plans again and refuses if
|
||||
the result differs.
|
||||
items:
|
||||
type: string
|
||||
|
||||
AclPlan:
|
||||
type: object
|
||||
required: [statements, warnings]
|
||||
properties:
|
||||
statements:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
warnings:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
AclObject:
|
||||
type: object
|
||||
required: [name, kind]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
description: >-
|
||||
TABLE, SEQUENCE, FUNCTION, PROCEDURE or TYPE — what the object is. A revoke turns it
|
||||
into the keyword it takes, ROUTINE for both routine kinds; a type's grants are read
|
||||
only.
|
||||
args:
|
||||
type: string
|
||||
description: identity arguments of a routine, which is what tells two of the same name apart
|
||||
|
||||
AclGrant:
|
||||
type: object
|
||||
required: [grantee, privileges, sources]
|
||||
properties:
|
||||
grantee:
|
||||
type: string
|
||||
privileges:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
object:
|
||||
$ref: "#/components/schemas/AclObject"
|
||||
future:
|
||||
type: string
|
||||
description: >-
|
||||
set for a default privilege, naming the kind of object it covers (TABLES, SEQUENCES,
|
||||
FUNCTIONS, TYPES, or SCHEMAS). On a schema, the defaults set in that schema; on the
|
||||
database, the ones set database-wide, which apply in every schema and which no schema's
|
||||
own defaults take back.
|
||||
sources:
|
||||
type: array
|
||||
description: >-
|
||||
the roles the grant comes from, each once — who granted it, or for a default privilege
|
||||
the role whose future objects it covers. A revoke of some of the grant's privileges
|
||||
takes them back from every source that gave them.
|
||||
items:
|
||||
$ref: "#/components/schemas/AclSource"
|
||||
|
||||
AclSource:
|
||||
type: object
|
||||
required: [role, privileges, reachable]
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
privileges:
|
||||
type: array
|
||||
description: >-
|
||||
what role gave of the grant's privileges. A revoke is held back only by a source out of
|
||||
reach that gave some of what it takes back.
|
||||
items:
|
||||
type: string
|
||||
reachable:
|
||||
type: boolean
|
||||
description: >-
|
||||
whether the data table's connection can take back what role gave. On an object that is
|
||||
the owner, when the connection acts for the owner, and otherwise the connection itself;
|
||||
for a default privilege, a creating role the connection acts for. What a source out of
|
||||
reach gave is not revocable from here; privileges only other sources gave still are.
|
||||
|
||||
DatatableAclInfo:
|
||||
type: object
|
||||
required: [owner, roles, editable, supports_maintain, dbname, grants, children]
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
roles:
|
||||
type: array
|
||||
description: the roles a change may name; empty unless the caller may change anything
|
||||
items:
|
||||
type: string
|
||||
editable:
|
||||
type: boolean
|
||||
description: whether the caller may plan and apply changes
|
||||
supports_maintain:
|
||||
type: boolean
|
||||
description: whether the server is Postgres 17+, which added the MAINTAIN table privilege
|
||||
dbname:
|
||||
type: string
|
||||
description: the database the target lives in
|
||||
grants:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AclGrant"
|
||||
children:
|
||||
type: array
|
||||
description: a database's schemas, or a schema's tables
|
||||
items:
|
||||
type: string
|
||||
|
||||
CustomInstanceDb:
|
||||
type: object
|
||||
required:
|
||||
@@ -36053,11 +35369,9 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
required: [database]
|
||||
properties:
|
||||
database:
|
||||
description: >-
|
||||
Set on an entry that owns its database. Absent on a fork's entry, which points at
|
||||
another workspace's data table instead.
|
||||
type: object
|
||||
properties:
|
||||
resource_type:
|
||||
@@ -36069,17 +35383,6 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- resource_type
|
||||
reference:
|
||||
description: >-
|
||||
The workspace and data table that govern this one. Server-owned: written by fork
|
||||
creation, and carried across a settings save whatever the request says.
|
||||
type: object
|
||||
required: [workspace_id, datatable]
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
datatable:
|
||||
type: string
|
||||
migrations_enabled:
|
||||
type: boolean
|
||||
description: Whether the SQL migrations feature is opted in for this data table
|
||||
@@ -36148,17 +35451,7 @@ components:
|
||||
|
||||
DataTableTables:
|
||||
type: object
|
||||
required:
|
||||
[
|
||||
datatable_name,
|
||||
schemas,
|
||||
instance,
|
||||
permissioned,
|
||||
usable_roles,
|
||||
default_role,
|
||||
can_create_schema,
|
||||
creatable_schemas,
|
||||
]
|
||||
required: [datatable_name, schemas]
|
||||
properties:
|
||||
datatable_name:
|
||||
type: string
|
||||
@@ -36171,26 +35464,6 @@ components:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
instance:
|
||||
type: boolean
|
||||
description: on the instance database, the only kind that can be under roles or have its access edited
|
||||
permissioned:
|
||||
type: boolean
|
||||
usable_roles:
|
||||
type: array
|
||||
description: the roles the caller may connect as, by name; empty when not under roles
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
can_create_schema:
|
||||
type: boolean
|
||||
description: whether the role the listing connected as may create schemas
|
||||
creatable_schemas:
|
||||
type: array
|
||||
description: the schemas the role the listing connected as may create in
|
||||
items:
|
||||
type: string
|
||||
|
||||
DataTableTableSchema:
|
||||
type: object
|
||||
|
||||
@@ -57,7 +57,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
|
||||
auth::TOKEN_PREFIX_LEN,
|
||||
auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN},
|
||||
cache::{self, future::FutureCachedExt},
|
||||
db::{DbWithOptAuthed, UserDB},
|
||||
error::{to_anyhow, Error, JsonResult, Result},
|
||||
@@ -1522,7 +1522,10 @@ async fn mint_raw_app_sdk_token(
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("sdk_app:{app_path}"), requested_exp),
|
||||
None => (
|
||||
format!("{RAW_APP_SDK_TOKEN_LABEL_PREFIX}{app_path}"),
|
||||
requested_exp,
|
||||
),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(label),
|
||||
@@ -1804,7 +1807,10 @@ pub async fn mint_app_embed_token(
|
||||
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
|
||||
(label, exp)
|
||||
}
|
||||
None => (format!("embed_app:{app_path}"), requested_exp),
|
||||
None => (
|
||||
format!("{APP_EMBED_TOKEN_LABEL_PREFIX}{app_path}"),
|
||||
requested_exp,
|
||||
),
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(label),
|
||||
|
||||
@@ -567,9 +567,6 @@ async fn set_config(
|
||||
};
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
if matches!(nc.trigger_kind, TriggerKind::Postgres) {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -617,9 +614,6 @@ async fn ping_config(
|
||||
)>,
|
||||
) -> Result<()> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
if matches!(trigger_kind, TriggerKind::Postgres) {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
|
||||
@@ -8343,9 +8343,8 @@ pub async fn run_wait_result_flow_by_version(
|
||||
/// job lives, in particular DuckDB, which runs in-process in the worker.
|
||||
///
|
||||
/// What it does permit is any statement against the workspace's data tables, writes and DDL
|
||||
/// included: the helper's body is an unrestricted SQL template. What that reaches is the
|
||||
/// operator's own data table role — the preview job is permissioned as them, so the executor
|
||||
/// resolves it under their tenancy like any other job.
|
||||
/// included: the helper's body is an unrestricted SQL template and data tables carry no
|
||||
/// per-user ACL. Narrowing that is a separate decision from this exemption.
|
||||
///
|
||||
/// The database argument is only half the target: the executor honors a `-- database`
|
||||
/// directive in the SQL over it, and `-- s3` redirects the result set, so both are refused.
|
||||
@@ -8577,7 +8576,7 @@ async fn run_inline_preview_script(
|
||||
#[cfg(not(feature = "run_inline"))]
|
||||
async fn run_inline_preview_script() -> error::Result<Response> {
|
||||
Err(error::Error::InternalErr(
|
||||
"inline preview requires the run_inline feature on the worker".to_string(),
|
||||
"inline preview requires the worker feature".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -8701,12 +8700,7 @@ fn register_potential_assets_on_inline_execution(
|
||||
.as_ref()
|
||||
.and_then(|args| args.get("database"))
|
||||
.map(|v| v.get().trim_matches('"'))
|
||||
.and_then(|dt| dt.strip_prefix("datatable://"))
|
||||
// `?role=` picks the connection, not the data table. Anything else after a `?` may be
|
||||
// part of a name stored before names were restricted, so it stays.
|
||||
.map(|dt| {
|
||||
windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name)
|
||||
});
|
||||
.and_then(|dt| dt.strip_prefix("datatable://"));
|
||||
if let Some(datatable) = datatable {
|
||||
let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap();
|
||||
let (schema, content) = if let Some(captures) = re.captures(&preview.content) {
|
||||
|
||||
@@ -352,17 +352,6 @@ async fn update_username_in_workpsace<'c>(
|
||||
new_username: &str,
|
||||
w_id: &str,
|
||||
) -> error::Result<()> {
|
||||
// ---- data table tenants ----
|
||||
// Tenants name the user, so the rename has to follow here too; a list left naming the old
|
||||
// username silently drops the access instead of moving it.
|
||||
windmill_common::workspaces::rename_datatable_tenant_in_workspace(
|
||||
tx,
|
||||
w_id,
|
||||
&format!("u/{old_username}"),
|
||||
&format!("u/{new_username}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---- instance and workspace users ----
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET username = $1 WHERE email = $2",
|
||||
|
||||
@@ -1639,7 +1639,7 @@ pub(crate) async fn tarball_workspace(
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color.clone(),
|
||||
operator_settings: row.operator_settings.clone(),
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable.clone()),
|
||||
datatable: row.datatable.clone(),
|
||||
slack_team_id: row.slack_team_id.clone(),
|
||||
slack_name: row.slack_name.clone(),
|
||||
slack_command_script: row.slack_command_script.clone(),
|
||||
@@ -1703,7 +1703,7 @@ pub(crate) async fn tarball_workspace(
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color,
|
||||
operator_settings: row.operator_settings,
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable),
|
||||
datatable: row.datatable,
|
||||
slack_team_id: row.slack_team_id,
|
||||
slack_name: row.slack_name,
|
||||
slack_command_script: row.slack_command_script,
|
||||
|
||||
@@ -75,7 +75,6 @@ bitflags.workspace = true
|
||||
once_cell.workspace = true
|
||||
phf.workspace = true
|
||||
tokio-postgres.workspace = true
|
||||
postgres-protocol.workspace = true
|
||||
postgres-native-tls.workspace = true
|
||||
native-tls.workspace = true
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ use crate::{
|
||||
};
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
|
||||
/// notifications and username overrides all key off them — so they must not be
|
||||
/// user-editable. `None` (no label) is treated as a user token.
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing —
|
||||
/// session cleanup, super_admin propagation, expiry notifications and username overrides
|
||||
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
|
||||
/// a user token.
|
||||
///
|
||||
/// This is the canonical copy. When updating it, also update its mirrors:
|
||||
/// - the `update_token_label` editability guard (SQL `WHERE`) in
|
||||
@@ -40,15 +41,30 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
&& !l.to_lowercase().starts_with("ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
// Short-lived tokens the server mints per app open or per service-account
|
||||
// impersonation (EE `users_ee.rs`) and nobody manages, so an expiry warning
|
||||
// for one is noise.
|
||||
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with("impersonation:")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
|
||||
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
||||
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
|
||||
|
||||
/// Label prefix, followed by the app path, of the token a raw app's bundle uses for the
|
||||
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
||||
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
|
||||
|
||||
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
|
||||
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`
|
||||
/// and `debugger-token` are minted by the editor and the debugger through that same handler,
|
||||
/// so reserving them would break those features.
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
|
||||
/// `debugger-token` and `ephemeral-test-connection: *` are minted by the editor, the debugger
|
||||
/// and object-storage connection tests through that same handler, so reserving them would
|
||||
/// break those features.
|
||||
///
|
||||
/// `username_override_from_label` trusts a label to name the entity acting only if it is in
|
||||
/// here, so anything added must be unmintable by a member.
|
||||
@@ -961,6 +977,9 @@ mod tests {
|
||||
assert!(!is_user_token(Some("Ephemeral lsp token")));
|
||||
assert!(!is_user_token(Some("debugger-token")));
|
||||
assert!(!is_user_token(Some("mcp-oauth-client")));
|
||||
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
|
||||
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
|
||||
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! The instance's data table role catalog.
|
||||
//!
|
||||
//! A data table role is a real Postgres login role on the Windmill cluster, named exactly as the
|
||||
//! user named it, shared by every instance database. Windmill decides who may ask for a role (the
|
||||
//! per-data-table tenant lists in [`crate::workspaces`]); Postgres decides what the role may then
|
||||
//! touch. The catalog here is only the first half's vocabulary plus the cluster provisioning.
|
||||
//!
|
||||
//! Entries are keyed by a generated id so a rename moves nothing else: tenants name the id.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// The connection every data table resolved to before roles existed (`custom_instance_user`). It
|
||||
/// owns every pre-existing object, so it is a reserved name rather than a catalog entry: never
|
||||
/// created, renamed or dropped.
|
||||
pub const ADMIN_DATATABLE_ROLE: &str = "admin";
|
||||
|
||||
/// The login the admin connection uses, and the role every created role is granted to — that
|
||||
/// membership is what later lets it `ALTER ... OWNER TO` a role and drop it.
|
||||
pub const CUSTOM_INSTANCE_USER: &str = "custom_instance_user";
|
||||
|
||||
/// One catalog entry, as stored in `datatable_role`. The password is per role and instance-wide;
|
||||
/// it belongs to the instance, not to any workspace's settings.
|
||||
/// No `Serialize`/`Deserialize`: the catalog is rows now, and a derived `Serialize` would emit
|
||||
/// `pwd` — the same way out for a credential that the hand-written `Debug` below closes on the log
|
||||
/// side.
|
||||
#[derive(Clone)]
|
||||
pub struct InstanceDatatableRole {
|
||||
/// The Postgres role name, verbatim.
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
/// Absent only for a role whose provisioning did not finish; resolving as it then errors
|
||||
/// rather than falling back to admin.
|
||||
///
|
||||
/// A plain string rather than a `StringOrSecretRef` like the instance user's password: that
|
||||
/// one is a secret ref because an operator supplies it and may want it to come from their own
|
||||
/// backend, while this one is minted here and never entered by anyone, so there is nothing for
|
||||
/// a ref to point at. Encrypting generated secrets at rest is a separate change that would
|
||||
/// take the replication password with it.
|
||||
pub pwd: Option<String>,
|
||||
}
|
||||
|
||||
/// Hand-written so `{:?}` on a catalog cannot put a live Postgres password in a log line or an
|
||||
/// audit record. Everything else about the entry is safe to print.
|
||||
impl std::fmt::Debug for InstanceDatatableRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("InstanceDatatableRole")
|
||||
.field("name", &self.name)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("pwd", &self.pwd.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub type DatatableRoleCatalog = BTreeMap<String, InstanceDatatableRole>;
|
||||
|
||||
/// Names Postgres or Windmill already owns. `admin` is excluded because it never reaches the
|
||||
/// cluster as a role name at all — it resolves to `custom_instance_user`.
|
||||
fn is_reserved_role_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower == ADMIN_DATATABLE_ROLE
|
||||
|| lower == "postgres"
|
||||
|| lower == "public"
|
||||
|| lower.starts_with("pg_")
|
||||
|| lower.starts_with("windmill_")
|
||||
|| lower.starts_with("custom_instance_")
|
||||
}
|
||||
|
||||
/// The charset is what makes every downstream interpolation safe: the name reaches Postgres as a
|
||||
/// quoted identifier, a `-- role <name>` annotation, and a `?role=` query parameter.
|
||||
pub fn validate_role_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() || name.len() > 63 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid data table role name '{name}': it must be between 1 and 63 characters"
|
||||
)));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid data table role name '{name}': only letters, digits, '_' and '-' are allowed"
|
||||
)));
|
||||
}
|
||||
if is_reserved_role_name(name) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{name}' is reserved and cannot be used as a data table role name"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this
|
||||
/// quotes any name — schema, table or role. Role names are validated as well
|
||||
/// ([`validate_role_name`]) because they also travel unquoted, in `-- role <name>` and `?role=`.
|
||||
pub fn quote_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// Serialize the mutations that are not already serialized by the row itself.
|
||||
///
|
||||
/// A create is an insert and a delete is a delete, which Postgres orders for us — the unique index
|
||||
/// on `name` is what makes two concurrent creates of the same name one winner and one error. What
|
||||
/// still needs it is the window between the cluster DDL and the row: `CREATE ROLE` is not visible
|
||||
/// to another transaction's `pg_roles` check until commit, so without this two creates of the same
|
||||
/// name both pass their existence check and one fails on the index having already made the login.
|
||||
/// Held for the transaction, so the DDL has to run on that same transaction to be covered.
|
||||
pub async fn lock_role_catalog(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<()> {
|
||||
sqlx::query!("SELECT pg_advisory_xact_lock(hashtext('datatable_role_catalog'))")
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A replication stream reads every row whatever a data table's roles grant. Turning roles on looks
|
||||
/// for streams holding this exclusive; whatever can start a Postgres trigger or capture streaming
|
||||
/// holds it shared on the transaction that commits it. So either the look sees the stream, or the
|
||||
/// stream's listener connects after roles are committed and refuses. Held for the transaction.
|
||||
pub async fn lock_datatable_streams(conn: &mut sqlx::PgConnection, exclusive: bool) -> Result<()> {
|
||||
let lock = if exclusive {
|
||||
"pg_advisory_xact_lock"
|
||||
} else {
|
||||
"pg_advisory_xact_lock_shared"
|
||||
};
|
||||
sqlx::query(&format!("SELECT {lock}(hashtext('datatable_streams'))"))
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disclosure: returns every role's stored Postgres password in plaintext. Any server path that
|
||||
/// has to resolve or name a role may call it — including handlers open to a workspace member, who
|
||||
/// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record
|
||||
/// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is
|
||||
/// hand-written to redact it for the same reason.
|
||||
pub async fn read_role_catalog(db: &DB) -> Result<DatatableRoleCatalog> {
|
||||
crate::datatable_roles_oss::read_role_catalog(db).await
|
||||
}
|
||||
|
||||
/// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one
|
||||
/// [`lock_role_catalog`] is protecting. Same disclosure contract.
|
||||
pub async fn read_role_catalog_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<DatatableRoleCatalog> {
|
||||
crate::datatable_roles_oss::read_role_catalog_tx(tx).await
|
||||
}
|
||||
|
||||
/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes.
|
||||
///
|
||||
/// Authorization: writes a generated Postgres credential. Callers MUST restrict this to superadmin
|
||||
/// paths and MUST hold [`lock_role_catalog`] on `tx`.
|
||||
pub async fn insert_role_catalog_entry(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: &str,
|
||||
role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await
|
||||
}
|
||||
|
||||
/// Update a role's recorded name, login flag and password. Same contract as
|
||||
/// [`insert_role_catalog_entry`].
|
||||
pub async fn update_role_catalog_entry(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: &str,
|
||||
role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::update_role_catalog_entry(tx, id, role).await
|
||||
}
|
||||
|
||||
/// Forget a role. Same contract as [`insert_role_catalog_entry`]; run it in the transaction that
|
||||
/// drops the cluster login, so the two cannot disagree.
|
||||
pub async fn delete_role_catalog_entry(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::delete_role_catalog_entry(tx, id).await
|
||||
}
|
||||
|
||||
/// Resolve the role a caller named to its catalog id. A disabled role is an error rather than a
|
||||
/// silent fallback: the caller asked for something the instance deliberately turned off.
|
||||
pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Result<&'a str> {
|
||||
let entry = catalog
|
||||
.iter()
|
||||
.find(|(_, role)| role.name == name)
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"'{name}' is not a data table role of this instance. Defined roles: {}.",
|
||||
catalog
|
||||
.values()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
})?;
|
||||
if !entry.1.enabled {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table role '{name}' is disabled on this instance"
|
||||
)));
|
||||
}
|
||||
Ok(entry.0.as_str())
|
||||
}
|
||||
|
||||
/// Every instance database the registry knows about. Role provisioning has to reach all of them:
|
||||
/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters.
|
||||
pub async fn registered_instance_databases(db: &DB) -> Result<Vec<String>> {
|
||||
crate::datatable_roles_oss::registered_instance_databases(db).await
|
||||
}
|
||||
|
||||
/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at
|
||||
/// database creation, and lazily whenever an instance data table is administered, so a database
|
||||
/// provisioned before a role existed is repaired rather than left silently unreachable.
|
||||
///
|
||||
/// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing.
|
||||
/// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the
|
||||
/// workspace governing a data table on it.
|
||||
pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> {
|
||||
crate::datatable_roles_oss::converge_connect_grants(db, dbname).await
|
||||
}
|
||||
|
||||
/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract.
|
||||
pub async fn converge_connect_grants_with(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
catalog: &DatatableRoleCatalog,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await
|
||||
}
|
||||
|
||||
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user`, and `CONNECT` on
|
||||
/// every registered database. No privileges beyond that — an admin grants them through SQL or the
|
||||
/// ACL editor.
|
||||
///
|
||||
/// Authorization: creates a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
|
||||
pub async fn create_instance_role(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::create_instance_role(tx, name, password).await
|
||||
}
|
||||
|
||||
/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
|
||||
pub async fn set_instance_role_login(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await
|
||||
}
|
||||
|
||||
/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one.
|
||||
///
|
||||
/// Authorization: renames a cluster-wide Postgres login. Callers MUST restrict this to superadmin
|
||||
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
|
||||
pub async fn rename_instance_role(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
from: &str,
|
||||
to: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await
|
||||
}
|
||||
|
||||
/// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the
|
||||
/// privileges granted to it are only visible from inside each database — hence the pass over the
|
||||
/// registry. An unreachable database aborts the whole delete: dropping the role while one database
|
||||
/// still holds objects owned by it leaves those objects owned by a numeric OID nobody can name.
|
||||
///
|
||||
/// Each pass runs as the instance's own Postgres user rather than `custom_instance_user`, which
|
||||
/// owns the databases and can therefore revoke a grant whoever made it. `custom_instance_user`
|
||||
/// could only undo what it granted itself, so a privilege planted by an operator in psql — the
|
||||
/// ordinary way privileges reach a role — would survive and block the drop.
|
||||
///
|
||||
/// Authorization: drops a cluster-wide Postgres login and reassigns everything it owns. Callers
|
||||
/// MUST restrict this to superadmin paths, and MUST hold [`lock_role_catalog`] on `tx`.
|
||||
///
|
||||
/// The per-database passes open their own connections and cannot join `tx`; the lock is what keeps
|
||||
/// a concurrent mutation out while they run. Only the final `DROP ROLE` is on `tx`, so it commits
|
||||
/// or rolls back with the catalog write that forgets the role. Those passes commit as they go, so
|
||||
/// callers MUST have disabled the role in an earlier committed transaction: a failure part-way
|
||||
/// then leaves a disabled role to retry, not an enabled one already stripped in some databases.
|
||||
pub async fn drop_instance_role(
|
||||
db: &DB,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::drop_instance_role(db, tx, name).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn role_names_are_validated() {
|
||||
assert!(validate_role_name("analytics").is_ok());
|
||||
assert!(validate_role_name("read-only_2").is_ok());
|
||||
assert!(validate_role_name("").is_err());
|
||||
assert!(validate_role_name(&"a".repeat(64)).is_err());
|
||||
assert!(validate_role_name("has space").is_err());
|
||||
assert!(validate_role_name("quote\"injection").is_err());
|
||||
// Reserved, case-insensitively.
|
||||
assert!(validate_role_name("admin").is_err());
|
||||
assert!(validate_role_name("Postgres").is_err());
|
||||
assert!(validate_role_name("pg_read_all_data").is_err());
|
||||
assert!(validate_role_name("windmill_user").is_err());
|
||||
assert!(validate_role_name("custom_instance_user").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_role_is_an_error_not_a_fallback() {
|
||||
let mut catalog = DatatableRoleCatalog::new();
|
||||
catalog.insert(
|
||||
"id1".to_string(),
|
||||
InstanceDatatableRole {
|
||||
name: "analytics".to_string(),
|
||||
enabled: false,
|
||||
pwd: Some("x".to_string()),
|
||||
},
|
||||
);
|
||||
assert!(role_id_by_name(&catalog, "analytics").is_err());
|
||||
assert!(role_id_by_name(&catalog, "nope").is_err());
|
||||
catalog.get_mut("id1").unwrap().enabled = true;
|
||||
assert_eq!(role_id_by_name(&catalog, "analytics").unwrap(), "id1");
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where data table roles come from: the enterprise implementation, or a refusal.
|
||||
//!
|
||||
//! Roles are an Enterprise Edition feature. An edition without them creates, grants and connects
|
||||
//! as none, and a data table saved under roles — by an enterprise build, before a downgrade — is
|
||||
//! refused rather than resolved as `admin`. A data table not under roles, asked for no role,
|
||||
//! resolves as it always has. `private` alone is not that edition: community builds carry it.
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// What every roles path answers without the Enterprise Edition. The frontend matches this exact
|
||||
/// sentence (`datatableUsableRoles.ts`) to read the refusal as "not under roles": reword both.
|
||||
pub fn datatable_roles_unavailable() -> Error {
|
||||
Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_roles_ee::{
|
||||
can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants,
|
||||
converge_connect_grants_with, create_instance_role, delete_role_catalog_entry,
|
||||
drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access,
|
||||
ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere,
|
||||
insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx,
|
||||
registered_instance_databases, rename_instance_role, resolve_datatable_role_connection,
|
||||
set_instance_role_login, update_role_catalog_entry,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use super::datatable_roles_unavailable as unavailable;
|
||||
use crate::{
|
||||
datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole},
|
||||
db::AuthedRef,
|
||||
error::Result,
|
||||
workspaces::{
|
||||
resolve_governing_datatable, DataTableRoleTenants, DatatableAccess, GoverningDatatable,
|
||||
},
|
||||
DB,
|
||||
};
|
||||
|
||||
type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
|
||||
|
||||
pub(crate) async fn read_role_catalog(_db: &DB) -> Result<DatatableRoleCatalog> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result<DatatableRoleCatalog> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_role_catalog_entry(
|
||||
_tx: &mut Tx<'_>,
|
||||
_id: &str,
|
||||
_role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_role_catalog_entry(
|
||||
_tx: &mut Tx<'_>,
|
||||
_id: &str,
|
||||
_role: &InstanceDatatableRole,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_role_catalog_entry(_tx: &mut Tx<'_>, _id: &str) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn registered_instance_databases(_db: &DB) -> Result<Vec<String>> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT`
|
||||
/// grants it was created with, `PUBLIC`'s included, as it did before roles existed.
|
||||
pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// As [`converge_connect_grants`].
|
||||
pub(crate) async fn converge_connect_grants_with(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
_catalog: &DatatableRoleCatalog,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn create_instance_role(
|
||||
_tx: &mut Tx<'_>,
|
||||
_name: &str,
|
||||
_password: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_role_login(
|
||||
_tx: &mut Tx<'_>,
|
||||
_name: &str,
|
||||
_enabled: bool,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_instance_role(
|
||||
_tx: &mut Tx<'_>,
|
||||
_from: &str,
|
||||
_to: &str,
|
||||
_password: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_instance_db_grant_options_unchecked(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// No tenant list covers anyone: there is no role to connect as.
|
||||
pub(crate) fn can_use_datatable_role(
|
||||
_tenants: &DataTableRoleTenants,
|
||||
_authed: &AuthedRef<'_>,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) async fn can_use_datatable_role_in_governing_workspace(
|
||||
_db: &DB,
|
||||
_governing_w_id: &str,
|
||||
_w_id: &str,
|
||||
_tenants: &DataTableRoleTenants,
|
||||
_access: &DatatableAccess<'_>,
|
||||
) -> Result<bool> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// Reached only for a data table under roles or a caller naming a role: both are refused.
|
||||
pub(crate) async fn resolve_datatable_role_connection(
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_name: &str,
|
||||
_governing: &GoverningDatatable,
|
||||
_db_resource: serde_json::Value,
|
||||
_role: Option<&str>,
|
||||
_access: DatatableAccess<'_>,
|
||||
) -> Result<serde_json::Value> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
/// A data table not under roles, asked for no role, is not a role decision and passes, as it
|
||||
/// did before roles existed. Anything else is refused.
|
||||
pub(crate) async fn ensure_can_use_datatable_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
_access: &DatatableAccess<'_>,
|
||||
_context: &str,
|
||||
) -> Result<()> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
if governing.datatable.permissions.is_none() && role.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
/// A data table not under roles is the `admin` connection for anyone who reaches it, as before
|
||||
/// roles existed. One under roles is refused.
|
||||
pub(crate) async fn ensure_datatable_admin_access(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
_access: &DatatableAccess<'_>,
|
||||
) -> Result<()> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
if governing.datatable.permissions.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn forget_datatable_role_everywhere(
|
||||
_tx: &mut Tx<'_>,
|
||||
_role_id: &str,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! The external Postgres cluster behind `external_instance` data tables and Ducklake catalogs.
|
||||
//!
|
||||
//! Windmill administers that cluster itself, logged in as the user in
|
||||
//! [`EXTERNAL_INSTANCE_PG_SETTING`]. It creates `custom_instance_user` and
|
||||
//! `custom_instance_replication_user` there, with passwords it generates and keeps in
|
||||
//! [`EXTERNAL_INSTANCE_PG_STATE_SETTING`]. They share their names with the roles on Windmill's own
|
||||
//! cluster, but they are different roles with different passwords.
|
||||
//!
|
||||
//! The cluster may hold data Windmill did not create. Two Windmill instances sharing one is not
|
||||
//! supported: each would keep resetting the passwords the other depends on.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
global_settings::{EXTERNAL_INSTANCE_PG_SETTING, EXTERNAL_INSTANCE_PG_STATE_SETTING},
|
||||
instance_config::{CustomInstanceDb, ExternalInstancePg},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// What Windmill keeps about the external cluster. Server-managed and hidden: never part of the
|
||||
/// instance config, never readable by an agent worker. No `Debug`: it carries live passwords.
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ExternalInstancePgState {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user_pwd: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub replication_pwd: Option<String>,
|
||||
/// The databases Windmill created on the cluster. It only ever drops one of these.
|
||||
#[serde(default)]
|
||||
pub databases: BTreeMap<String, CustomInstanceDb>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_setup: Option<ExternalInstancePgSetupReport>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ExternalInstancePgSetupReport {
|
||||
/// No step failed. Warnings leave it true.
|
||||
pub success: bool,
|
||||
pub finished_at: chrono::DateTime<chrono::Utc>,
|
||||
pub steps: Vec<ExternalInstancePgSetupStep>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ExternalInstancePgSetupStep {
|
||||
pub name: String,
|
||||
pub status: SetupStepStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SetupStepStatus {
|
||||
Ok,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// The status the settings page shows without running anything.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ExternalInstancePgStatus {
|
||||
pub configured: bool,
|
||||
pub database_count: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_setup: Option<ExternalInstancePgSetupReport>,
|
||||
}
|
||||
|
||||
/// Authorization: returns the cluster's admin password and checks nothing. Callers MUST be
|
||||
/// superadmin or an internal server path.
|
||||
pub(crate) async fn read_external_instance_pg_config<'c>(
|
||||
executor: impl sqlx::PgExecutor<'c>,
|
||||
) -> Result<Option<ExternalInstancePg>> {
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
EXTERNAL_INSTANCE_PG_SETTING
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
value
|
||||
.map(|v| {
|
||||
serde_json::from_value(v).map_err(|e| {
|
||||
Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_SETTING}: {e}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Authorization: returns the passwords Windmill generated on the cluster and checks nothing.
|
||||
/// Callers MUST be superadmin or an internal server path.
|
||||
pub(crate) async fn read_external_instance_pg_state<'c>(
|
||||
executor: impl sqlx::PgExecutor<'c>,
|
||||
) -> Result<ExternalInstancePgState> {
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
EXTERNAL_INSTANCE_PG_STATE_SETTING
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
match value {
|
||||
None => Ok(ExternalInstancePgState::default()),
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| {
|
||||
Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_STATE_SETTING}: {e}"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn external_instance_pg_status(db: &DB) -> Result<ExternalInstancePgStatus> {
|
||||
let configured = read_external_instance_pg_config(db).await?.is_some();
|
||||
let state = read_external_instance_pg_state(db).await?;
|
||||
Ok(ExternalInstancePgStatus {
|
||||
configured,
|
||||
database_count: state.databases.len(),
|
||||
last_setup: state.last_setup,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse to unset the cluster while Windmill still has databases on it: every data table and
|
||||
/// Ducklake catalog there would stop resolving. Allowed on every edition, so a downgraded
|
||||
/// instance can still clear a setting it no longer uses.
|
||||
pub async fn ensure_external_instance_pg_removable(db: &DB) -> Result<()> {
|
||||
let state = read_external_instance_pg_state(db).await?;
|
||||
if state.databases.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let names = state
|
||||
.databases
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(Error::BadRequest(format!(
|
||||
"The external instance cluster still holds databases Windmill created ({names}). Drop \
|
||||
them before removing {EXTERNAL_INSTANCE_PG_SETTING}."
|
||||
)))
|
||||
}
|
||||
|
||||
/// Check a write to [`EXTERNAL_INSTANCE_PG_SETTING`] before it happens: `None`, null or an empty
|
||||
/// string unsets it. Every writer of global settings calls this, the per-key and bulk endpoints
|
||||
/// as well as the declarative sync.
|
||||
pub async fn check_external_instance_pg_write(
|
||||
db: &DB,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
match value {
|
||||
None | Some(serde_json::Value::Null) => ensure_external_instance_pg_removable(db).await,
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
|
||||
ensure_external_instance_pg_removable(db).await
|
||||
}
|
||||
Some(value) => {
|
||||
crate::external_instance_pg_oss::validate_external_instance_pg_setting(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converge the external cluster on the configured login: check what it can do, create or update
|
||||
/// Windmill's two roles with the stored passwords, and report anything that would get in the way.
|
||||
/// With `rotate_passwords`, generate new passwords first. Safe to run again; running it again is
|
||||
/// how a failed rotation is repaired.
|
||||
///
|
||||
/// Authorization: administers the external cluster with its admin credentials and checks nothing.
|
||||
/// Callers MUST be superadmin.
|
||||
pub async fn setup_external_instance_pg_unchecked(
|
||||
db: &DB,
|
||||
rotate_passwords: bool,
|
||||
) -> Result<ExternalInstancePgSetupReport> {
|
||||
crate::external_instance_pg_oss::setup_external_instance_pg_unchecked(db, rotate_passwords)
|
||||
.await
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* 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 external instance cluster comes from: the enterprise implementation, or a refusal.
|
||||
//! `private` alone is not that edition: community builds carry it.
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub fn external_instance_pg_unavailable() -> Error {
|
||||
Error::BadRequest(
|
||||
"External instance databases are a Windmill Enterprise Edition feature".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::external_instance_pg_ee::{
|
||||
setup_external_instance_pg_unchecked, validate_external_instance_pg_setting,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use super::external_instance_pg_unavailable as unavailable;
|
||||
use crate::{error::Result, external_instance_pg::ExternalInstancePgSetupReport, DB};
|
||||
|
||||
pub(crate) fn validate_external_instance_pg_setting(_value: &serde_json::Value) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn setup_external_instance_pg_unchecked(
|
||||
_db: &DB,
|
||||
_rotate_passwords: bool,
|
||||
) -> Result<ExternalInstancePgSetupReport> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,6 @@ pub const SAML_METADATA_SETTING: &str = "saml_metadata";
|
||||
pub const SMTP_SETTING: &str = "smtp_settings";
|
||||
pub const TEAMS_SETTING: &str = "teams";
|
||||
pub const INDEXER_SETTING: &str = "indexer_settings";
|
||||
pub const EXTERNAL_INSTANCE_PG_SETTING: &str = "external_instance_pg";
|
||||
pub const EXTERNAL_INSTANCE_PG_STATE_SETTING: &str = "external_instance_pg_state";
|
||||
pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result";
|
||||
|
||||
pub const UNIQUE_ID_SETTING: &str = "uid";
|
||||
@@ -357,9 +355,6 @@ pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[
|
||||
// resolve datatable connections through the dedicated datatable endpoints, never these.
|
||||
"custom_instance_pg_databases",
|
||||
"custom_instance_replication_pwd",
|
||||
// The external cluster's admin login, and the passwords Windmill generated on it.
|
||||
EXTERNAL_INSTANCE_PG_SETTING,
|
||||
EXTERNAL_INSTANCE_PG_STATE_SETTING,
|
||||
];
|
||||
|
||||
/// Whether an agent worker may read the given global setting over HTTP.
|
||||
|
||||
@@ -350,8 +350,6 @@ pub struct GlobalSettings {
|
||||
pub ducklake_settings: Option<DucklakeSettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom_instance_pg_databases: Option<CustomInstancePgDatabases>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub external_instance_pg: Option<ExternalInstancePg>,
|
||||
|
||||
// Opaque settings (EE-private structs or no clear schema)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -835,36 +833,6 @@ pub struct CustomInstanceDbLogs {
|
||||
pub replication_user_error: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External instance PG cluster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The external Postgres cluster Windmill manages for `external_instance` data tables and Ducklake
|
||||
/// catalogs. `user` logs in as the cluster's administrator: it needs `CREATEDB` and `CREATEROLE`.
|
||||
/// `dbname` is only where that login connects to run cluster-wide statements.
|
||||
///
|
||||
/// Every field defaults rather than being required: this deserializes as part of the whole
|
||||
/// instance config, and one malformed row must not make every other setting unreadable. The
|
||||
/// write path and every use reject an incomplete value instead.
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
|
||||
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
|
||||
pub struct ExternalInstancePg {
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<StringOrSecretRef>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dbname: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sslmode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root_certificate_pem: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autoscaling (worker config)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1001,7 +969,6 @@ pub const PROTECTED_SETTINGS: &[&str] = &[
|
||||
"ducklake_settings",
|
||||
"custom_instance_pg_databases",
|
||||
"custom_instance_replication_pwd",
|
||||
"external_instance_pg_state",
|
||||
"uid",
|
||||
"rsa_keys",
|
||||
"jwt_secret",
|
||||
@@ -1027,8 +994,6 @@ pub const HIDDEN_SETTINGS: &[&str] = &[
|
||||
// Server-only (written by setup/refresh via direct SQL), never operator-authored —
|
||||
// hidden so the config machinery can't read, rewrite, or drop it.
|
||||
"custom_instance_replication_pwd",
|
||||
// Same for the passwords and database registry Windmill keeps for the external cluster.
|
||||
"external_instance_pg_state",
|
||||
];
|
||||
|
||||
/// Top-level settings whose entire value is sensitive and must be fully redacted in logs.
|
||||
@@ -1040,7 +1005,6 @@ const SENSITIVE_SETTINGS: &[&str] = &[
|
||||
"license_key",
|
||||
"ducklake_user_pg_pwd",
|
||||
"custom_instance_replication_pwd",
|
||||
"external_instance_pg_state",
|
||||
"pip_index_url",
|
||||
"pip_extra_index_url",
|
||||
"npm_config_registry",
|
||||
@@ -1066,7 +1030,6 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[
|
||||
&["secret_key", "serviceAccountKey"],
|
||||
),
|
||||
("custom_instance_pg_databases", &["user_pwd"]),
|
||||
("external_instance_pg", &["password"]),
|
||||
];
|
||||
|
||||
fn redact_json_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
@@ -1396,13 +1359,6 @@ pub async fn sync_global_settings_declarative(
|
||||
.map_err(|e| anyhow::anyhow!("{origins_key}: {e}"))?;
|
||||
|
||||
let diff = diff_global_settings(current, desired, ApplyMode::Replace);
|
||||
let external_pg_key = crate::global_settings::EXTERNAL_INSTANCE_PG_SETTING;
|
||||
if diff.deletes.iter().any(|k| k == external_pg_key) {
|
||||
crate::external_instance_pg::check_external_instance_pg_write(db, None).await?;
|
||||
}
|
||||
if let Some(value) = diff.upserts.get(external_pg_key) {
|
||||
crate::external_instance_pg::check_external_instance_pg_write(db, Some(value)).await?;
|
||||
}
|
||||
apply_settings_diff(db, &diff).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -1535,10 +1491,6 @@ pub fn resolve_env_refs(settings: &mut GlobalSettings) -> Result<(), String> {
|
||||
resolve_env_option(&mut pg.user_pwd)?;
|
||||
}
|
||||
|
||||
if let Some(pg) = &mut settings.external_instance_pg {
|
||||
resolve_env_option(&mut pg.password)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2508,33 +2460,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_generated_db_passwords_are_isolated_from_config() {
|
||||
// These hold passwords the server generates: written by setup/refresh via direct SQL,
|
||||
// never operator-authored. They must stay out of the declarative config surface
|
||||
// (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop them.
|
||||
for key in [
|
||||
"custom_instance_replication_pwd",
|
||||
"external_instance_pg_state",
|
||||
] {
|
||||
assert!(HIDDEN_SETTINGS.contains(&key), "{key}");
|
||||
assert!(PROTECTED_SETTINGS.contains(&key), "{key}");
|
||||
assert!(SENSITIVE_SETTINGS.contains(&key), "{key}");
|
||||
fn custom_instance_replication_pwd_is_isolated_from_config() {
|
||||
// The replication-role password is server-only: written by setup/refresh via direct
|
||||
// SQL, never operator-authored. It must stay out of the declarative config surface
|
||||
// (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop it.
|
||||
assert!(HIDDEN_SETTINGS.contains(&"custom_instance_replication_pwd"));
|
||||
assert!(PROTECTED_SETTINGS.contains(&"custom_instance_replication_pwd"));
|
||||
assert!(SENSITIVE_SETTINGS.contains(&"custom_instance_replication_pwd"));
|
||||
|
||||
// A stray desired value (e.g. flattened into `extra`) is ignored, not upserted.
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(key.to_string(), serde_json::json!("attacker-set"));
|
||||
let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge);
|
||||
assert!(diff.upserts.is_empty(), "{key} must not be upserted");
|
||||
// A stray desired value (e.g. flattened into `extra`) is ignored, not upserted.
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"custom_instance_replication_pwd".to_string(),
|
||||
serde_json::json!("attacker-set"),
|
||||
);
|
||||
let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge);
|
||||
assert!(
|
||||
diff.upserts.is_empty(),
|
||||
"hidden setting must not be upserted"
|
||||
);
|
||||
|
||||
// A current value is never deleted by a Replace that omits it.
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(key.to_string(), serde_json::json!("live"));
|
||||
let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace);
|
||||
assert!(
|
||||
!diff.deletes.contains(&key.to_string()),
|
||||
"{key} must not be deleted"
|
||||
);
|
||||
}
|
||||
// A current value is never deleted by a Replace that omits it.
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"custom_instance_replication_pwd".to_string(),
|
||||
serde_json::json!("live"),
|
||||
);
|
||||
let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace);
|
||||
assert!(
|
||||
!diff
|
||||
.deletes
|
||||
.contains(&"custom_instance_replication_pwd".to_string()),
|
||||
"hidden setting must not be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,10 +37,6 @@ pub mod bench;
|
||||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod data_metrics;
|
||||
pub mod datatable_roles;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod datatable_roles_ee;
|
||||
pub mod datatable_roles_oss;
|
||||
pub mod db;
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
mod db_entra_ee;
|
||||
@@ -58,10 +54,6 @@ pub mod ee_oss;
|
||||
pub mod email_ee;
|
||||
pub mod email_oss;
|
||||
pub mod error;
|
||||
pub mod external_instance_pg;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod external_instance_pg_ee;
|
||||
pub mod external_instance_pg_oss;
|
||||
pub mod external_ip;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod feature_usage_ee;
|
||||
@@ -1028,20 +1020,6 @@ impl Future for TokioPgConnection {
|
||||
}
|
||||
}
|
||||
|
||||
impl TokioPgConnection {
|
||||
/// Drive the connection and hand back what the server sends outside of a query's response —
|
||||
/// notices above all, which driving it as a future silently discards.
|
||||
pub fn poll_message(
|
||||
&mut self,
|
||||
cx: &mut core::task::Context<'_>,
|
||||
) -> core::task::Poll<Option<Result<tokio_postgres::AsyncMessage, tokio_postgres::Error>>> {
|
||||
match self {
|
||||
TokioPgConnection::Tls(conn) => conn.poll_message(cx),
|
||||
TokioPgConnection::NoTls(conn) => conn.poll_message(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PgDatabase {
|
||||
/// The role the connection logs in as, whichever way it authenticates.
|
||||
pub fn login_name(&self) -> &str {
|
||||
@@ -1536,41 +1514,6 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `custom_instance_user` holds on an instance database.
|
||||
///
|
||||
/// `WITH GRANT OPTION` throughout: this is the connection every data table resolves to as `admin`,
|
||||
/// and it is the one that hands privileges to data table roles. Postgres refuses to let a role pass
|
||||
/// on a privilege it does not itself hold with grant option, so without these an admin could own
|
||||
/// the database and still be unable to grant `SELECT` on it to `analytics`.
|
||||
pub(crate) fn instance_db_grants(dbname: &str) -> String {
|
||||
format!(
|
||||
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
|
||||
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') THEN
|
||||
GRANT USAGE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION;
|
||||
GRANT CREATE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;
|
||||
END IF;
|
||||
END $$;"
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-apply [`instance_db_grants`] to an instance database provisioned before data table roles
|
||||
/// existed, whose grants carry no grant option. Connects as the instance's own Postgres user —
|
||||
/// the database and `public` schema owner — since only it can hand out an option it holds.
|
||||
///
|
||||
/// Authorization: reaches an instance database with the server's own credentials and checks
|
||||
/// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of
|
||||
/// the workspace governing a data table on it.
|
||||
pub async fn ensure_instance_db_grant_options_unchecked(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
) -> error::Result<()> {
|
||||
crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await
|
||||
}
|
||||
|
||||
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
|
||||
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake").
|
||||
pub async fn create_custom_instance_database(
|
||||
@@ -1610,7 +1553,17 @@ 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(&instance_db_grants(dbname)).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
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to grant permissions on '{}': {}. Continuing.",
|
||||
dbname,
|
||||
@@ -1639,13 +1592,6 @@ pub async fn create_custom_instance_database(
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT
|
||||
// would otherwise let every role in regardless of what this instance defines. Best-effort: a
|
||||
// failure here leaves the database usable as `admin`, and the next role change repairs it.
|
||||
if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await {
|
||||
tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}");
|
||||
}
|
||||
|
||||
tracing::info!("Created custom instance database '{}'", dbname);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -329,7 +329,6 @@ 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),
|
||||
@@ -1717,13 +1716,6 @@ 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,
|
||||
@@ -2012,23 +2004,6 @@ fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result<String, String>
|
||||
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
||||
}
|
||||
|
||||
fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: RenameSchemaPayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?;
|
||||
if !matches!(db_type, DbType::Postgresql | DbType::Snowflake) || p.ducklake.is_some() {
|
||||
return Err(format!(
|
||||
"Renaming a schema is not supported on {:?}",
|
||||
db_type
|
||||
));
|
||||
}
|
||||
let query = format!(
|
||||
"ALTER SCHEMA {} RENAME TO {};",
|
||||
qi(&p.schema, db_type),
|
||||
qi(&p.new_schema, db_type)
|
||||
);
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
fn expand_create_table(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: CreateTablePayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?;
|
||||
@@ -2623,9 +2598,7 @@ WHERE table_catalog = current_database()",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
// pg_catalog is readable by everyone: without the privilege check this lists
|
||||
// tables of schemas the connection's role cannot even enter.
|
||||
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE')".to_string(),
|
||||
"\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(),
|
||||
",\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(),
|
||||
@@ -4128,13 +4101,6 @@ mod tests {
|
||||
assert_eq!(sql, "DROP SCHEMA \"old_schema\" CASCADE;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_rename_schema() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
||||
assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_create_schema_with_ducklake() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"s","ducklake":"lake"}"#;
|
||||
@@ -4502,7 +4468,6 @@ mod tests {
|
||||
assert!(sql.contains("schema_name"));
|
||||
assert!(sql.contains("table_name"));
|
||||
assert!(sql.contains("c.relkind = 'r'"));
|
||||
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1082,83 +1082,6 @@ pub struct SqlAnnotations {
|
||||
pub raw_output: bool,
|
||||
}
|
||||
|
||||
impl SqlAnnotations {
|
||||
/// The data table role a query declares as `-- role <name>`, if any. Only meaningful against a
|
||||
/// `datatable://` database that is under roles; absent means the data table's default role.
|
||||
///
|
||||
/// Hand-written rather than derived because the value matters, not just the presence, and
|
||||
/// because the executor needs it before it knows the connection is a data table at all. Like
|
||||
/// every annotation it lives in the leading comment block.
|
||||
///
|
||||
/// A leading comment whose first word is `role` is an annotation *attempt*, and a malformed
|
||||
/// one is an error. The alternative — ignoring what does not parse — resolves the query to the
|
||||
/// data table's default role instead, so a typo silently runs it under a login the author did
|
||||
/// not choose, which is the opposite of what naming a role is for. Only callers that already
|
||||
/// know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its
|
||||
/// comments.
|
||||
pub fn datatable_role(code: &str) -> error::Result<Option<String>> {
|
||||
for line in code.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !line.starts_with("--") {
|
||||
break;
|
||||
}
|
||||
// The keyword may be followed by whitespace, `:` or `=` — `role x`, `role: x`,
|
||||
// `role=x`, `Role = x` all open an attempt, while `rolexyz` does not. Each accepted
|
||||
// separator is one spelling that would otherwise take the `continue` below and run the
|
||||
// query as the data table's default role, which is the silence this exists to remove.
|
||||
let body = line[2..].trim_start();
|
||||
let Some(after) = body
|
||||
.get(..4)
|
||||
.filter(|kw| kw.eq_ignore_ascii_case("role"))
|
||||
.map(|_| &body[4..])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !after.is_empty()
|
||||
&& !after.starts_with(char::is_whitespace)
|
||||
&& !after.starts_with([':', '='])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Past this point the line is an attempt to name a role, so a malformed one is an
|
||||
// error rather than a miss. Falling through would run the query as the data table's
|
||||
// default role — quietly, and under a login the author did not choose.
|
||||
let after = after.trim_start();
|
||||
let after = after.strip_prefix([':', '=']).unwrap_or(after);
|
||||
let mut tokens = after.split_whitespace();
|
||||
let role = tokens
|
||||
.next()
|
||||
.map(|role| role.strip_suffix(';').unwrap_or(role));
|
||||
let rest = tokens.next();
|
||||
match (role, rest) {
|
||||
(Some(role), None)
|
||||
if !role.is_empty()
|
||||
&& role.len() <= 63
|
||||
&& role
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
|
||||
{
|
||||
return Ok(Some(role.to_string()));
|
||||
}
|
||||
_ => {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Malformed data table role annotation: `{line}`. Write it as \
|
||||
`-- role <name>` on a line of its own, where <name> is letters, digits, \
|
||||
'_' or '-'. A comment in the leading block that starts with the word \
|
||||
'role' is read as this annotation; move it below the first statement if \
|
||||
it is prose."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[annotations("#")]
|
||||
pub struct BashAnnotations {
|
||||
pub docker: bool,
|
||||
@@ -2730,56 +2653,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn datatable_role_is_read_from_the_leading_comment_block() {
|
||||
let role = |code| SqlAnnotations::datatable_role(code);
|
||||
assert_eq!(
|
||||
role("-- role analytics\nSELECT 1").unwrap(),
|
||||
Some("analytics".to_string())
|
||||
);
|
||||
// Blank lines and other annotations before it are fine.
|
||||
assert_eq!(
|
||||
role("\n-- prepare\n-- role read_only\nSELECT 1").unwrap(),
|
||||
Some("read_only".to_string())
|
||||
);
|
||||
// Past the first statement it is an ordinary comment, not an annotation.
|
||||
assert_eq!(role("SELECT 1;\n-- role analytics").unwrap(), None);
|
||||
assert_eq!(role("SELECT 1").unwrap(), None);
|
||||
|
||||
// Unambiguous intent is honoured: the keyword matches case-insensitively, a trailing
|
||||
// semicolon is a habit carried over from SQL rather than a different role, and the colon
|
||||
// spelling is the one most likely to be typed.
|
||||
for accepted in [
|
||||
"-- Role operator\nSELECT 1",
|
||||
"-- role operator;\nSELECT 1",
|
||||
"-- role: operator\nSELECT 1",
|
||||
"-- role:operator\nSELECT 1",
|
||||
"-- role=operator\nSELECT 1",
|
||||
"-- Role = operator\nSELECT 1",
|
||||
] {
|
||||
assert_eq!(
|
||||
role(accepted).unwrap(),
|
||||
Some("operator".to_string()),
|
||||
"not honoured: {accepted}"
|
||||
);
|
||||
}
|
||||
|
||||
// Anything else opening with the word is refused rather than resolved to the default role:
|
||||
// the whole point of naming one is to not run as something else.
|
||||
for near_miss in [
|
||||
"-- role operator -- why\nSELECT 1",
|
||||
"-- role an;alytics\nSELECT 1",
|
||||
"-- role\nSELECT 1",
|
||||
"-- role:\nSELECT 1",
|
||||
"-- role based access is handled below\nSELECT 1",
|
||||
] {
|
||||
assert!(role(near_miss).is_err(), "silently ignored: {near_miss}");
|
||||
}
|
||||
|
||||
// A word that merely starts with the keyword is not an attempt.
|
||||
assert_eq!(role("-- rolebased notes\nSELECT 1").unwrap(), None);
|
||||
}
|
||||
|
||||
fn matcher(id: &str) -> WorkspaceMatcher {
|
||||
WorkspaceMatcher { id: id.to_string(), include_forks: false }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize};
|
||||
use strum::AsRefStr;
|
||||
|
||||
use crate::{
|
||||
datatable_roles::{ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER},
|
||||
error::{self, to_anyhow, Error, Result},
|
||||
get_database_url,
|
||||
secret_backend::{get_secret_value, is_external_stored_value},
|
||||
@@ -1293,17 +1292,9 @@ impl Default for DataTableForkBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTable {
|
||||
/// Set on a *terminal* entry — one that owns its database. Mutually exclusive with
|
||||
/// [`DataTable::reference`]; [`validate_datatable_shape`] is the one place that enforces it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database: Option<DataTableDatabase>,
|
||||
/// Set on a *pointer* entry — one that names another workspace's entry and owns nothing.
|
||||
/// A keep-original fork gets one of these instead of a copy of the parent's entry, so there is
|
||||
/// nothing local for a fork admin to widen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<DataTableReference>,
|
||||
pub database: DataTableDatabase,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from: Option<DataTableForkedFrom>,
|
||||
/// Whether the SQL-migrations feature is opted in for this data table.
|
||||
@@ -1311,85 +1302,22 @@ pub struct DataTable {
|
||||
/// when migrations already exist (see `datatable_migrations_enabled`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub migrations_enabled: Option<bool>,
|
||||
/// Who may connect as which role. Absent = unpermissioned: every caller connects as `admin`,
|
||||
/// which is how data tables behaved before roles existed. Only meaningful on a terminal entry;
|
||||
/// a pointer is governed by what it points at.
|
||||
///
|
||||
/// Never leaves the instance: stripped from the workspace export and ignored on import, since
|
||||
/// tenants are workspace-scoped names and syncing them would make repo write access a second
|
||||
/// door onto the access decision.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<DataTablePermissions>,
|
||||
}
|
||||
|
||||
/// A pointer at another workspace's data table entry.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct DataTableReference {
|
||||
pub workspace_id: String,
|
||||
pub datatable: String,
|
||||
}
|
||||
|
||||
/// The access decision for one data table: which role a caller gets, and who may ask for each.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct DataTablePermissions {
|
||||
/// A role id from the instance catalog, or `admin`. Absent = `admin`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_role: Option<String>,
|
||||
/// Keyed by instance role id, plus the reserved `admin` key. A role absent from this map
|
||||
/// cannot be used on this data table at all, whatever the instance catalog says.
|
||||
#[serde(default)]
|
||||
pub roles: std::collections::BTreeMap<String, DataTableRoleTenants>,
|
||||
}
|
||||
|
||||
impl DataTablePermissions {
|
||||
pub fn default_role(&self) -> &str {
|
||||
self.default_role.as_deref().unwrap_or(ADMIN_DATATABLE_ROLE)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct DataTableRoleTenants {
|
||||
/// `u/<user>`, `g/<group>`, `f/<folder>`, or `*` for every member.
|
||||
#[serde(default)]
|
||||
pub tenants: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every member of the governing workspace.
|
||||
pub const DATATABLE_TENANT_WILDCARD: &str = "*";
|
||||
|
||||
/// How deep a chain of pointer entries may go before it is called a loop. Data tables are not
|
||||
/// expected to chain at all — a fork points at its parent — so this only has to be generous
|
||||
/// enough to survive a fork of a fork.
|
||||
const DATATABLE_REFERENCE_MAX_DEPTH: usize = 20;
|
||||
|
||||
/// Exactly one of `database` and `reference` must be set. Called wherever an entry is persisted,
|
||||
/// so nothing downstream has to handle an entry that is both or neither.
|
||||
pub fn validate_datatable_shape(name: &str, dt: &DataTable) -> Result<()> {
|
||||
match (&dt.database, &dt.reference) {
|
||||
(Some(_), None) | (None, Some(_)) => Ok(()),
|
||||
(Some(_), Some(_)) => Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' both owns a database and points at another one"
|
||||
))),
|
||||
(None, None) => Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' names neither a database nor another data table"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTableForkedFrom {
|
||||
/// Schema snapshot at fork time
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTableDatabase {
|
||||
pub resource_type: DataTableCatalogResourceType,
|
||||
pub resource_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Copy)]
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(AsRefStr)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
@@ -1425,13 +1353,37 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>)
|
||||
))
|
||||
}
|
||||
|
||||
/// Read one workspace's data table entry, without following a pointer.
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_inner(db, w_id, name, false).await
|
||||
}
|
||||
|
||||
/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger
|
||||
/// connections: custom-instance datatables resolve to
|
||||
/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres
|
||||
/// datatables resolve to the user's own resource unchanged; configuring it for
|
||||
/// replication there is the user's responsibility.
|
||||
///
|
||||
/// Disclosure: this is the primitive [`resolve_governing_datatable`] calls on every path, so it is
|
||||
/// deliberately open to anything that has to resolve a data table, including for a workspace the
|
||||
/// caller does not belong to. What it returns is not: callers MUST NOT put `permissions` into a
|
||||
/// response, an export or a log — it names the governing workspace's users, groups and folders.
|
||||
pub async fn read_datatable_entry(db: &DB, w_id: &str, name: &str) -> Result<DataTable> {
|
||||
/// Authorization: like its `_unchecked` sibling, returns resolved connection
|
||||
/// credentials and performs no authorization — callers MUST have already authorized
|
||||
/// access to the datatable (e.g. the trigger's own create-time check).
|
||||
pub async fn get_datatable_replication_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
get_datatable_resource_inner(db, w_id, name, true).await
|
||||
}
|
||||
|
||||
async fn get_datatable_resource_inner(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
replication: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
let datatables = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT ws.datatable->'datatables' AS datatables
|
||||
@@ -1449,495 +1401,37 @@ pub async fn read_datatable_entry(db: &DB, w_id: &str, name: &str) -> Result<Dat
|
||||
.and_then(|d| d.get(name))
|
||||
.filter(|v| !v.is_null())
|
||||
.ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?;
|
||||
Ok(serde_json::from_value::<DataTable>(datatable.clone())?)
|
||||
}
|
||||
let datatable = serde_json::from_value::<DataTable>(datatable.clone())?;
|
||||
|
||||
/// The terminal entry a reference chain lands on: the workspace that governs the data table, the
|
||||
/// entry name there, and the entry itself. A terminal entry resolves to itself.
|
||||
///
|
||||
/// Every decision downstream — which database to connect to, whose `permissions` apply, whose
|
||||
/// members tenants are evaluated against, who may administer it — is taken on this, never on the
|
||||
/// entry the caller named.
|
||||
///
|
||||
/// Authorization: resolving deliberately crosses into the governing workspace, so it answers for a
|
||||
/// workspace the caller may not belong to and checks nothing itself. It is the input to the
|
||||
/// checks, not one of them: callers MUST pass what it returns to
|
||||
/// [`can_use_datatable_role_in_governing_workspace`] or [`ensure_datatable_admin_access`] before
|
||||
/// acting on it, and MUST NOT return its `permissions` or `workspace_id` to a caller from
|
||||
/// elsewhere without gating on the answer.
|
||||
pub struct GoverningDatatable {
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub datatable: DataTable,
|
||||
}
|
||||
|
||||
impl GoverningDatatable {
|
||||
/// Backed by the Windmill instance's own Postgres, which is the only substrate data table
|
||||
/// roles apply to.
|
||||
pub fn is_instance(&self) -> bool {
|
||||
self.datatable
|
||||
.database
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_governing_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<GoverningDatatable> {
|
||||
let mut workspace_id = w_id.to_string();
|
||||
let mut name = name.to_string();
|
||||
let mut hops = 0;
|
||||
for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH {
|
||||
let datatable = read_datatable_entry(db, &workspace_id, &name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if hops == 0 {
|
||||
e
|
||||
} else {
|
||||
// A pointer outlives the workspace it names: deleting one only nulls the fork
|
||||
// lineage, it does not sweep the entries that pointed at it. Say which one is
|
||||
// gone rather than reporting a data table this workspace never had.
|
||||
Error::NotFound(format!(
|
||||
"Data table '{name}' of workspace '{workspace_id}' governs this one and no \
|
||||
longer exists. A superadmin can point this data table somewhere else."
|
||||
))
|
||||
}
|
||||
})?;
|
||||
hops += 1;
|
||||
validate_datatable_shape(&name, &datatable)?;
|
||||
match &datatable.reference {
|
||||
None => return Ok(GoverningDatatable { workspace_id, name, datatable }),
|
||||
Some(reference) => {
|
||||
workspace_id = reference.workspace_id.clone();
|
||||
name = reference.datatable.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"Data table '{name}' points at another data table through more than \
|
||||
{DATATABLE_REFERENCE_MAX_DEPTH} hops; the chain is likely a loop"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Build the `admin` connection for a governing entry: `custom_instance_user` for an instance
|
||||
/// database, the user's own resource for a BYO-postgres one.
|
||||
async fn resolve_datatable_connection_unchecked(
|
||||
db: &DB,
|
||||
governing: &GoverningDatatable,
|
||||
replication: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
let database = governing
|
||||
.datatable
|
||||
.database
|
||||
.as_ref()
|
||||
.expect("a governing entry owns a database");
|
||||
if database.resource_type == DataTableCatalogResourceType::Instance {
|
||||
let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance
|
||||
{
|
||||
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
pg_creds.dbname = database.resource_path.clone();
|
||||
pg_creds.dbname = datatable.database.resource_path.clone();
|
||||
if replication {
|
||||
pg_creds.user = Some("custom_instance_replication_user".to_string());
|
||||
pg_creds.password = Some(get_custom_pg_instance_replication_password(&db).await?);
|
||||
} else {
|
||||
pg_creds.user = Some(CUSTOM_INSTANCE_USER.to_string());
|
||||
pg_creds.user = Some("custom_instance_user".to_string());
|
||||
pg_creds.password = Some(get_custom_pg_instance_password(&db).await?);
|
||||
}
|
||||
serde_json::to_value(&pg_creds)
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))?
|
||||
} else {
|
||||
// Name the data table too: the caller asked for one by name, and a bare
|
||||
// "resource f/x/y does not exist" leaves them to work out which one points at it.
|
||||
transform_json_unchecked(
|
||||
&serde_json::Value::String(format!("$res:{}", database.resource_path)),
|
||||
&governing.workspace_id,
|
||||
&serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)),
|
||||
w_id,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::NotFound(m) => Error::NotFound(format!("data table {}: {m}", governing.name)),
|
||||
Error::NotFound(m) => Error::NotFound(format!("data table {name}: {m}")),
|
||||
e => e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a data table to connection credentials **without authorizing anything**: always the
|
||||
/// `admin` connection.
|
||||
///
|
||||
/// Authorization: callers MUST have authorized access already. Anything that acts for a user or a
|
||||
/// job wants [`get_datatable_resource_from_db`] instead.
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
resolve_datatable_connection_unchecked(db, &governing, false).await
|
||||
}
|
||||
|
||||
/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger
|
||||
/// connections: custom-instance datatables resolve to
|
||||
/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres
|
||||
/// datatables resolve to the user's own resource unchanged; configuring it for
|
||||
/// replication there is the user's responsibility.
|
||||
///
|
||||
/// Authorization: a replication connection reads every row whatever the roles grant, so no role or
|
||||
/// admin check makes it safe. Callers MUST refuse a data table under roles outright — the Postgres
|
||||
/// trigger crate's `ensure_not_under_roles` — and turning roles on is refused while one streams.
|
||||
pub async fn get_datatable_replication_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
resolve_datatable_connection_unchecked(db, &governing, true).await
|
||||
}
|
||||
|
||||
/// The identity a resolution is made for. `Unchecked` is for callers that authorized already;
|
||||
/// everything else is checked against the governing entry's tenants.
|
||||
pub enum DatatableAccess<'a> {
|
||||
/// Reaches every role. For callers that already authorized, or that have no user at all.
|
||||
Unchecked,
|
||||
Authed(crate::db::AuthedRef<'a>),
|
||||
/// A job's owner, without reading the job row — only fetched if the data table turns out to
|
||||
/// be permissioned.
|
||||
PermissionedAs {
|
||||
permissioned_as: &'a str,
|
||||
email: &'a str,
|
||||
},
|
||||
/// A job identified by id; its owner is read from `v2_job`. For agent workers and anything
|
||||
/// else that authenticates as infrastructure rather than as the job's user.
|
||||
Job(uuid::Uuid),
|
||||
/// No identity established. Unpermissioned data tables resolve as before; permissioned ones
|
||||
/// are refused, so a caller predating this feature fails closed.
|
||||
NoIdentity,
|
||||
}
|
||||
|
||||
/// Does one tenant list cover this identity? Admins of the governing workspace pass everything —
|
||||
/// they can edit the tenant lists anyway, so refusing them would only be theatre.
|
||||
pub fn can_use_datatable_role(
|
||||
tenants: &DataTableRoleTenants,
|
||||
authed: &crate::db::AuthedRef<'_>,
|
||||
) -> bool {
|
||||
crate::datatable_roles_oss::can_use_datatable_role(tenants, authed)
|
||||
}
|
||||
|
||||
/// Evaluate a tenant list **as a member of the governing workspace**, whoever is calling.
|
||||
///
|
||||
/// A caller reaching a data table through a pointer is a member of some other workspace, and being
|
||||
/// its admin means nothing here — that is the whole point of the pointer. They are looked up in
|
||||
/// the governing workspace by email and evaluated there, or refused when they are not a member.
|
||||
/// A `g/` or `f/` permissioned-as from a foreign workspace is refused outright: those names are
|
||||
/// defined per workspace and mean nothing outside the one that defined them.
|
||||
pub async fn can_use_datatable_role_in_governing_workspace(
|
||||
db: &DB,
|
||||
governing_w_id: &str,
|
||||
w_id: &str,
|
||||
tenants: &DataTableRoleTenants,
|
||||
access: &DatatableAccess<'_>,
|
||||
) -> Result<bool> {
|
||||
crate::datatable_roles_oss::can_use_datatable_role_in_governing_workspace(
|
||||
db,
|
||||
governing_w_id,
|
||||
w_id,
|
||||
tenants,
|
||||
access,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a data table to connection credentials for one identity.
|
||||
///
|
||||
/// This is the chokepoint: everything that opens a connection to a data table on someone's behalf
|
||||
/// goes through it. `role` is the role name the caller asked for — the `-- role` annotation, the
|
||||
/// `?role=` on a `datatable://` reference, or `None` for the data table's default.
|
||||
///
|
||||
/// The resolved role **logs in as itself**. Never `SET ROLE`: a script could `RESET ROLE` its way
|
||||
/// back to admin.
|
||||
pub async fn get_datatable_resource_from_db(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
access: DatatableAccess<'_>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let governing = resolve_governing_datatable(db, w_id, name).await?;
|
||||
let db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?;
|
||||
// Not under roles and asked for none: the `admin` connection, as before roles existed, in
|
||||
// every edition. Anything else is a role decision.
|
||||
if governing.datatable.permissions.is_none() && role.is_none() {
|
||||
return Ok(db_resource);
|
||||
}
|
||||
crate::datatable_roles_oss::resolve_datatable_role_connection(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
&governing,
|
||||
db_resource,
|
||||
role,
|
||||
access,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Would the chokepoint accept this identity connecting as this role? Answers without resolving
|
||||
/// credentials, for callers that want to refuse early and say which thing was refused.
|
||||
///
|
||||
/// Not the security boundary — [`get_datatable_resource_from_db`] re-checks when it actually opens
|
||||
/// the connection. This is what turns "permission denied for table x" into a message naming the
|
||||
/// migration and the role.
|
||||
pub async fn ensure_can_use_datatable_role(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
access: &DatatableAccess<'_>,
|
||||
context: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::ensure_can_use_datatable_role(db, w_id, name, role, access, context)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Gate the operations that see the whole database whatever the roles grant: a migration that
|
||||
/// declares no role, exports, and editing the permissions themselves. Not replication, which a
|
||||
/// data table under roles refuses whoever asks (see `ensure_not_under_roles`). Passing
|
||||
/// means the caller could have connected as `admin` anyway.
|
||||
pub async fn ensure_datatable_admin_access(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
access: &DatatableAccess<'_>,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::ensure_datatable_admin_access(db, w_id, name, access).await
|
||||
}
|
||||
|
||||
/// Rewrite the `permissions` of every data table entry of one workspace, in the caller's
|
||||
/// transaction. `change` reports whether it touched anything; the row is only written when
|
||||
/// something did.
|
||||
///
|
||||
/// Authorization: writes an access decision for any workspace named, with an arbitrary mutation,
|
||||
/// and checks nothing. It exists for the cascades below — the transaction that frees or renames a
|
||||
/// principal — so callers MUST be the operation that made the principal change, and MUST run in
|
||||
/// its transaction. Anything editing a decision on purpose belongs in the permissions endpoint,
|
||||
/// which is gated on the workspace that governs the data table.
|
||||
///
|
||||
/// The tenant lists name principals of this workspace, so anything that frees or renames one has
|
||||
/// to come through here in the same transaction that frees it — otherwise a `u/alice` reused by a
|
||||
/// later account silently inherits her access.
|
||||
pub async fn update_datatable_permissions_in_workspace<F>(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
change: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: Fn(&mut DataTablePermissions) -> bool,
|
||||
{
|
||||
let Some(mut settings) = sqlx::query_scalar!(
|
||||
"SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten() else {
|
||||
return Ok(());
|
||||
})?
|
||||
};
|
||||
|
||||
let Some(datatables) = settings
|
||||
.get_mut("datatables")
|
||||
.and_then(|d| d.as_object_mut())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut touched = false;
|
||||
for entry in datatables.values_mut() {
|
||||
let Some(permissions) = entry.get("permissions").filter(|p| !p.is_null()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut permissions) =
|
||||
serde_json::from_value::<DataTablePermissions>(permissions.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if change(&mut permissions) {
|
||||
entry["permissions"] = serde_json::to_value(&permissions)
|
||||
.map_err(|e| Error::internal_err(format!("serializing permissions: {e}")))?;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if touched {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2",
|
||||
settings,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a freed principal (`u/alice`, `g/analysts`, `f/finance`) from every tenant list of one
|
||||
/// workspace. Same contract as [`update_datatable_permissions_in_workspace`]: for the transaction
|
||||
/// that frees the principal, not for editing a decision.
|
||||
pub async fn remove_datatable_tenant_in_workspace(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
tenant: &str,
|
||||
) -> Result<()> {
|
||||
update_datatable_permissions_in_workspace(tx, w_id, |permissions| {
|
||||
let mut touched = false;
|
||||
for role in permissions.roles.values_mut() {
|
||||
let before = role.tenants.len();
|
||||
role.tenants.retain(|t| t != tenant);
|
||||
touched |= role.tenants.len() != before;
|
||||
}
|
||||
touched
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Follow a renamed principal through every tenant list of one workspace. Same contract as
|
||||
/// [`update_datatable_permissions_in_workspace`].
|
||||
pub async fn rename_datatable_tenant_in_workspace(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<()> {
|
||||
update_datatable_permissions_in_workspace(tx, w_id, |permissions| {
|
||||
let mut touched = false;
|
||||
for role in permissions.roles.values_mut() {
|
||||
let mut role_touched = false;
|
||||
for tenant in role.tenants.iter_mut() {
|
||||
if tenant == old {
|
||||
*tenant = new.to_string();
|
||||
role_touched = true;
|
||||
}
|
||||
}
|
||||
if role_touched {
|
||||
// The rename can collide with a name already in the list, and the two need not be
|
||||
// adjacent — `Vec::dedup` only collapses neighbours, so it would leave the pair.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
role.tenants.retain(|t| seen.insert(t.clone()));
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
touched
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Strip a deleted instance role from every workspace that had tenanted it, so nothing is left
|
||||
/// naming a role that no longer exists.
|
||||
///
|
||||
/// Authorization: reaches every workspace on the instance. Callers MUST be the superadmin path
|
||||
/// dropping the role from the cluster — it exists to follow that, not to edit tenants.
|
||||
///
|
||||
/// Takes that path's transaction rather than opening its own: run afterwards, a failure part-way
|
||||
/// leaves the catalog row already gone, so the retry answers `NotFound` while some workspaces
|
||||
/// still name a role nothing can connect as. In the transaction, the cluster drop, the catalog row
|
||||
/// and every tenant list commit together or not at all. A data table whose default role was the deleted one falls
|
||||
/// back to `admin` — the one role that is always present.
|
||||
pub async fn forget_datatable_role_everywhere(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
role_id: &str,
|
||||
) -> Result<()> {
|
||||
crate::datatable_roles_oss::forget_datatable_role_everywhere(tx, role_id).await
|
||||
}
|
||||
|
||||
/// Drop the `permissions` block from a `workspace_settings.datatable` value before it leaves the
|
||||
/// server.
|
||||
///
|
||||
/// Who may connect as which role is an access decision, not configuration, and its tenants name
|
||||
/// principals of one workspace — `g/analysts` in dev is a different group from `g/analysts` in
|
||||
/// prod. Shipping it would both mean nothing at the far end and turn a settings push into a way to
|
||||
/// widen access, so the decision stays where it was made. [`DataTable`] deserializes fine without
|
||||
/// it, and the settings-editing endpoint carries the stored block across untouched.
|
||||
pub fn strip_datatable_permissions(
|
||||
datatable: Option<serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut datatable = datatable?;
|
||||
if let Some(entries) = datatable
|
||||
.get_mut("datatables")
|
||||
.and_then(|d| d.as_object_mut())
|
||||
{
|
||||
for entry in entries.values_mut() {
|
||||
if let Some(entry) = entry.as_object_mut() {
|
||||
entry.remove("permissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(datatable)
|
||||
}
|
||||
|
||||
/// As [`parse_datatable_ref`], except that an entry whose stored name itself contains `?` — which
|
||||
/// names could before they were restricted — resolves by that exact name, without a role. It is
|
||||
/// looked up first, so `sales?role=x` never reaches a different entry than the one stored so.
|
||||
pub async fn parse_datatable_ref_for(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
reference: &str,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
if reference.contains('?') {
|
||||
let exists = sqlx::query_scalar::<_, Option<bool>>(
|
||||
"SELECT (datatable->'datatables') ? $2 FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(reference)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
if exists {
|
||||
return Ok((reference.to_string(), None));
|
||||
}
|
||||
}
|
||||
let (name, role) = parse_datatable_ref(reference)?;
|
||||
Ok((name.to_string(), role.map(str::to_string)))
|
||||
}
|
||||
|
||||
/// Split a `datatable://` reference into its name and the role its query string names.
|
||||
///
|
||||
/// A query string that does not parse is an error rather than an absent role. Falling back would
|
||||
/// resolve the reference to the data table's default role, so `?Role=analytics` or a mistyped
|
||||
/// `?role=` would quietly connect as something the caller did not ask for — the same trap as a
|
||||
/// malformed `-- role` annotation, and `role` is the only parameter a reference takes.
|
||||
pub fn parse_datatable_ref(reference: &str) -> Result<(&str, Option<&str>)> {
|
||||
let (name, query) = reference.split_once('?').unwrap_or((reference, ""));
|
||||
let mut role = None;
|
||||
for param in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (key, value) = param.split_once('=').unwrap_or((param, ""));
|
||||
if !key.eq_ignore_ascii_case("role") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' carries an unknown parameter '{key}'. \
|
||||
The only one it takes is `?role=<name>`."
|
||||
)));
|
||||
}
|
||||
if role.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' names a role more than once."
|
||||
)));
|
||||
}
|
||||
if value.is_empty() || !is_datatable_role_name(value) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{name}' has a malformed role '{value}'. Write it as \
|
||||
`?role=<name>`, where <name> is letters, digits, '_' or '-'."
|
||||
)));
|
||||
}
|
||||
role = Some(value);
|
||||
}
|
||||
Ok((name, role))
|
||||
}
|
||||
|
||||
fn is_datatable_role_name(role: &str) -> bool {
|
||||
!role.is_empty()
|
||||
&& role.len() <= 63
|
||||
&& role
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
Ok(db_resource)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
@@ -3148,130 +2642,6 @@ async fn transform_json_unchecked(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tenants(list: &[&str]) -> DataTableRoleTenants {
|
||||
DataTableRoleTenants { tenants: list.iter().map(|t| t.to_string()).collect() }
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[test]
|
||||
fn a_tenant_list_covers_users_groups_folders_and_the_wildcard() {
|
||||
let groups = vec!["analysts".to_string()];
|
||||
let folders = vec![("finance".to_string(), true, false)];
|
||||
let scopes = None;
|
||||
let token_prefix = None;
|
||||
let is_admin = false;
|
||||
let is_operator = false;
|
||||
let authed = crate::db::AuthedRef {
|
||||
email: "alice@windmill.dev",
|
||||
username: "alice",
|
||||
is_admin: &is_admin,
|
||||
is_operator: &is_operator,
|
||||
groups: &groups,
|
||||
folders: &folders,
|
||||
scopes: &scopes,
|
||||
token_prefix: &token_prefix,
|
||||
};
|
||||
|
||||
assert!(can_use_datatable_role(&tenants(&["u/alice"]), &authed));
|
||||
assert!(can_use_datatable_role(&tenants(&["g/analysts"]), &authed));
|
||||
assert!(can_use_datatable_role(&tenants(&["f/finance"]), &authed));
|
||||
assert!(can_use_datatable_role(&tenants(&["*"]), &authed));
|
||||
assert!(!can_use_datatable_role(&tenants(&[]), &authed));
|
||||
assert!(!can_use_datatable_role(
|
||||
&tenants(&["u/bob", "g/ops"]),
|
||||
&authed
|
||||
));
|
||||
// A bare name is not a principal: only the three prefixes and the wildcard match.
|
||||
assert!(!can_use_datatable_role(&tenants(&["alice"]), &authed));
|
||||
|
||||
// An admin of the governing workspace reaches every role: they can edit the lists anyway.
|
||||
let is_admin = true;
|
||||
let admin = crate::db::AuthedRef { is_admin: &is_admin, ..authed };
|
||||
assert!(can_use_datatable_role(&tenants(&[]), &admin));
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
#[test]
|
||||
fn without_the_enterprise_edition_no_tenant_list_covers_anyone() {
|
||||
let groups = vec![];
|
||||
let folders = vec![];
|
||||
let scopes = None;
|
||||
let token_prefix = None;
|
||||
let is_admin = true;
|
||||
let is_operator = false;
|
||||
let admin = crate::db::AuthedRef {
|
||||
email: "alice@windmill.dev",
|
||||
username: "alice",
|
||||
is_admin: &is_admin,
|
||||
is_operator: &is_operator,
|
||||
groups: &groups,
|
||||
folders: &folders,
|
||||
scopes: &scopes,
|
||||
token_prefix: &token_prefix,
|
||||
};
|
||||
assert!(!can_use_datatable_role(&tenants(&["*"]), &admin));
|
||||
assert!(!can_use_datatable_role(&tenants(&["u/alice"]), &admin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_datatable_ref_splits_off_its_role() {
|
||||
assert_eq!(parse_datatable_ref("sales").unwrap(), ("sales", None));
|
||||
assert_eq!(
|
||||
parse_datatable_ref("sales?role=analytics").unwrap(),
|
||||
("sales", Some("analytics"))
|
||||
);
|
||||
// The key matches case-insensitively, the way the `-- role` annotation does.
|
||||
assert_eq!(
|
||||
parse_datatable_ref("sales?Role=analytics").unwrap(),
|
||||
("sales", Some("analytics"))
|
||||
);
|
||||
|
||||
// A query string that does not parse is refused rather than read as "no role": resolving
|
||||
// it to the data table's default would connect as a login the caller never asked for.
|
||||
for malformed in [
|
||||
"sales?role=",
|
||||
"sales?role=an;alytics",
|
||||
"sales?x=1&role=analytics",
|
||||
"sales?role=a&role=b",
|
||||
] {
|
||||
assert!(
|
||||
parse_datatable_ref(malformed).is_err(),
|
||||
"silently ignored: {malformed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_owns_a_database_or_points_at_one_but_never_both() {
|
||||
let terminal = DataTable {
|
||||
database: Some(DataTableDatabase {
|
||||
resource_type: DataTableCatalogResourceType::Instance,
|
||||
resource_path: "dt_main".to_string(),
|
||||
}),
|
||||
reference: None,
|
||||
forked_from: None,
|
||||
migrations_enabled: None,
|
||||
permissions: None,
|
||||
};
|
||||
assert!(validate_datatable_shape("main", &terminal).is_ok());
|
||||
|
||||
let pointer = DataTable {
|
||||
database: None,
|
||||
reference: Some(DataTableReference {
|
||||
workspace_id: "prod".to_string(),
|
||||
datatable: "main".to_string(),
|
||||
}),
|
||||
..terminal.clone()
|
||||
};
|
||||
assert!(validate_datatable_shape("main", &pointer).is_ok());
|
||||
|
||||
let both = DataTable { database: terminal.database.clone(), ..pointer.clone() };
|
||||
assert!(validate_datatable_shape("main", &both).is_err());
|
||||
|
||||
let neither = DataTable { database: None, reference: None, ..terminal.clone() };
|
||||
assert!(validate_datatable_shape("main", &neither).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_fork_branch() {
|
||||
// Generated fork (`wm-fork-abc`) and dev workspace (`staging`) forms.
|
||||
|
||||
@@ -21,12 +21,11 @@ use windmill_common::{
|
||||
use windmill_git_sync::DeployedObject;
|
||||
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed};
|
||||
use windmill_trigger::{Trigger, TriggerCrud, TriggerData, TriggerMode};
|
||||
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
|
||||
|
||||
use super::{
|
||||
check_if_valid_publication_for_postgres_version, create_logical_replication_slot,
|
||||
create_pg_publication, drop_publication, ensure_not_under_roles, generate_random_string,
|
||||
get_default_pg_connection,
|
||||
create_pg_publication, drop_publication, generate_random_string, get_default_pg_connection,
|
||||
mapper::{Mapper, MappingInfo},
|
||||
PostgresConfig, PostgresConfigRequest, PostgresPublicationReplication, PostgresTrigger,
|
||||
PublicationData, Relations, Slot, SlotList, TableToTrack, TemplateScript, TestPostgresConfig,
|
||||
@@ -65,29 +64,6 @@ impl TriggerCrud for PostgresTrigger {
|
||||
DeployedObject::PostgresTrigger { path, parent_path }
|
||||
}
|
||||
|
||||
async fn validate_config(
|
||||
&self,
|
||||
db: &DB,
|
||||
config: &Self::TriggerConfigRequest,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
ensure_not_under_roles(db, workspace_id, &config.postgres_resource_path).await
|
||||
}
|
||||
|
||||
async fn authorize_set_trigger_mode(
|
||||
&self,
|
||||
_authed: &ApiAuthed,
|
||||
tx: &mut PgConnection,
|
||||
_workspace_id: &str,
|
||||
_path: &str,
|
||||
mode: &TriggerMode,
|
||||
) -> Result<()> {
|
||||
if *mode != TriggerMode::Disabled {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(tx, false).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_trigger(
|
||||
&self,
|
||||
db: &DB,
|
||||
@@ -96,7 +72,6 @@ impl TriggerCrud for PostgresTrigger {
|
||||
w_id: &str,
|
||||
trigger: TriggerData<Self::TriggerConfigRequest>,
|
||||
) -> Result<()> {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
||||
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
||||
let Self::TriggerConfigRequest {
|
||||
@@ -186,7 +161,6 @@ impl TriggerCrud for PostgresTrigger {
|
||||
path: &str,
|
||||
trigger: TriggerData<Self::TriggerConfigRequest>,
|
||||
) -> Result<()> {
|
||||
windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?;
|
||||
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
||||
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
||||
let Self::TriggerConfigRequest {
|
||||
|
||||
@@ -374,32 +374,6 @@ pub async fn get_raw_postgres_connection(
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// A replication stream reads every row of every table whatever the data table's roles grant, so
|
||||
/// the two don't mix: a data table under roles takes no triggers or captures, and roles cannot be
|
||||
/// turned on while one is enabled on it.
|
||||
pub async fn ensure_not_under_roles(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
postgres_resource_path: &str,
|
||||
) -> Result<()> {
|
||||
let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") else {
|
||||
return Ok(());
|
||||
};
|
||||
if windmill_common::workspaces::resolve_governing_datatable(db, w_id, datatable_name)
|
||||
.await?
|
||||
.datatable
|
||||
.permissions
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table '{datatable_name}' is under roles, and a Postgres trigger or capture \
|
||||
cannot read one: a replication stream sees every row whatever the roles grant. \
|
||||
Turn its roles off to stream it."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_postgres_resource(
|
||||
authed: &ApiAuthed,
|
||||
user_db: Option<UserDB>,
|
||||
@@ -408,7 +382,6 @@ pub async fn resolve_postgres_resource(
|
||||
w_id: &str,
|
||||
) -> Result<Postgres> {
|
||||
if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") {
|
||||
ensure_not_under_roles(db, w_id, postgres_resource_path).await?;
|
||||
// Trigger connections (publication/slot management + logical replication) run
|
||||
// as the dedicated replication user on custom-instance databases.
|
||||
let resource_value =
|
||||
|
||||
@@ -20,8 +20,7 @@ use windmill_common::{
|
||||
use windmill_trigger::{listener::ListeningTrigger, trigger_helpers::TriggerJobArgs, Listener};
|
||||
|
||||
use super::{
|
||||
drop_publication, ensure_not_under_roles, get_default_pg_connection,
|
||||
get_raw_postgres_connection,
|
||||
drop_publication, get_default_pg_connection, get_raw_postgres_connection,
|
||||
handler::drop_logical_replication_slot,
|
||||
relation::RelationConverter,
|
||||
replication_message::{
|
||||
@@ -136,8 +135,8 @@ impl PostgresSimpleClient {
|
||||
/// Resolves the Postgres resource, validates that the configured publication and
|
||||
/// replication slot still exist, and opens a fresh logical replication stream.
|
||||
///
|
||||
/// Returns `Error::BadConfig` when the publication or slot is missing, or the
|
||||
/// data table is under roles (unrecoverable misconfigurations). Any other error is treated as transient
|
||||
/// Returns `Error::BadConfig` when the publication or slot is missing (an
|
||||
/// unrecoverable misconfiguration). Any other error is treated as transient
|
||||
/// (connection refused, network interruption, ...) and is retried by the caller.
|
||||
/// The resource is re-resolved on every call so credential rotations are picked
|
||||
/// up across reconnections.
|
||||
@@ -150,14 +149,6 @@ async fn connect_logical_replication_stream(
|
||||
let PostgresConfig { postgres_resource_path, publication_name, replication_slot_name, .. } =
|
||||
trigger_config;
|
||||
|
||||
// Retrying cannot lift roles, so this disables the trigger like a missing slot does.
|
||||
ensure_not_under_roles(db, workspace_id, postgres_resource_path)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::BadRequest(msg) => Error::BadConfig(msg),
|
||||
e => e,
|
||||
})?;
|
||||
|
||||
let database = resolve_postgres_resource(
|
||||
authed,
|
||||
Some(UserDB::new(db.clone())),
|
||||
|
||||
@@ -64,25 +64,16 @@ pub async fn get_ducklake_from_agent_http(
|
||||
.await
|
||||
}
|
||||
|
||||
/// An agent worker authenticates as infrastructure, not as the job's user, so the job id travels
|
||||
/// with the request: the server reads the job's owner from it and evaluates the data table's
|
||||
/// tenants against them. A worker predating this sends neither, and the server fails it closed on
|
||||
/// a data table under roles.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_datatable_resource_from_agent_http(
|
||||
client: &HttpClient,
|
||||
name: &str,
|
||||
w_id: &str,
|
||||
role: Option<&str>,
|
||||
job_id: &uuid::Uuid,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let role_query = role
|
||||
.map(|r| format!("&role={}", urlencoding::encode(r)))
|
||||
.unwrap_or_default();
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/w/{}/agent_workers/get_datatable_resource/{}?job_id={}{}",
|
||||
w_id, &name, job_id, role_query
|
||||
"/api/w/{}/agent_workers/get_datatable_resource/{}",
|
||||
w_id, &name
|
||||
))
|
||||
.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, get_ducklake_from_db_unchecked,
|
||||
strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType,
|
||||
get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked,
|
||||
strip_fork_reserved_attach_args, DucklakeCatalogResourceType,
|
||||
};
|
||||
use windmill_common::PgDatabase;
|
||||
use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE;
|
||||
@@ -1494,9 +1494,13 @@ 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)
|
||||
.await?
|
||||
} else if let Some(q) = transform_attach_datatable(
|
||||
&query_block,
|
||||
conn,
|
||||
&mut hidden_passwords,
|
||||
&job.workspace_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
probe_blocks.extend(q);
|
||||
} else {
|
||||
@@ -1571,9 +1575,13 @@ 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)
|
||||
.await?
|
||||
} else if let Some(datatable_query) = transform_attach_datatable(
|
||||
&query_block,
|
||||
conn,
|
||||
&mut hidden_passwords,
|
||||
&job.workspace_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
v.extend(datatable_query);
|
||||
} else {
|
||||
@@ -2601,79 +2609,33 @@ fn fork_defer_statements(
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
struct AttachedDatatable<'a> {
|
||||
/// The data table reference, query string included; a bare `datatable` is `main`.
|
||||
reference: String,
|
||||
alias: &'a str,
|
||||
}
|
||||
|
||||
/// `ATTACH 'datatable[://<name>][?role=<role>]' AS <alias>`. A bare `datatable` names the default
|
||||
/// data table, so the role query string has to be accepted with and without an explicit name. The
|
||||
/// reference is split only once the workspace can be read, because a stored name may contain `?`.
|
||||
fn parse_attach_datatable(query: &str) -> Option<AttachedDatatable<'_>> {
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE: regex::Regex = regex::Regex::new(
|
||||
r"(?i)ATTACH\s*'datatable(://[^':]+|\?[^':]*)?'\s*AS\s+([^ ;]+)"
|
||||
).unwrap();
|
||||
}
|
||||
let cap = RE.captures(query)?;
|
||||
let reference = match cap.get(1).map(|m| m.as_str()) {
|
||||
Some(named) if named.starts_with("://") => named[3..].to_string(),
|
||||
Some(query) => format!("main{query}"),
|
||||
None => "main".to_string(),
|
||||
};
|
||||
let alias = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
||||
Some(AttachedDatatable { reference, alias })
|
||||
}
|
||||
|
||||
async fn transform_attach_datatable(
|
||||
query: &str,
|
||||
conn: &Connection,
|
||||
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
||||
job: &MiniPulledJob,
|
||||
w_id: &str,
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
let Some(attached) = parse_attach_datatable(query) else {
|
||||
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 {
|
||||
return Ok(None);
|
||||
};
|
||||
let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main");
|
||||
let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
// A query string that does not parse is refused rather than dropped: attaching under the
|
||||
// default role when the statement asked for another one is the failure this guards.
|
||||
let db_resource = match conn {
|
||||
Connection::Http(client) => {
|
||||
let (name, role) =
|
||||
windmill_common::workspaces::parse_datatable_ref(&attached.reference)?;
|
||||
get_datatable_resource_from_agent_http(client, name, &job.workspace_id, role, &job.id)
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
let (name, role) = windmill_common::workspaces::parse_datatable_ref_for(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&attached.reference,
|
||||
)
|
||||
.await?;
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&name,
|
||||
role.as_deref(),
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
get_datatable_resource_from_agent_http(client, name, w_id).await?
|
||||
}
|
||||
Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?,
|
||||
};
|
||||
|
||||
if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) {
|
||||
hidden_passwords.lock().unwrap().push(pwd.to_string());
|
||||
}
|
||||
|
||||
Ok(Some(pg_secret_attach_statements(
|
||||
db_resource,
|
||||
attached.alias,
|
||||
)?))
|
||||
Ok(Some(pg_secret_attach_statements(db_resource, alias_name)?))
|
||||
}
|
||||
|
||||
// Secret names must be plain identifiers; the hash keeps two aliases distinct even
|
||||
@@ -2791,45 +2753,6 @@ pub struct Arg {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attach_datatable_parses_name_and_role() {
|
||||
let reference_of = |q: &str| parse_attach_datatable(q).unwrap().reference;
|
||||
let named =
|
||||
parse_attach_datatable("ATTACH 'datatable://sales?role=analytics' AS dt").unwrap();
|
||||
assert_eq!(
|
||||
(named.reference.as_str(), named.alias),
|
||||
("sales?role=analytics", "dt")
|
||||
);
|
||||
// A bare `datatable` is the default one, and still takes a role.
|
||||
assert_eq!(
|
||||
reference_of("ATTACH 'datatable?role=analytics' AS dt"),
|
||||
"main?role=analytics"
|
||||
);
|
||||
assert_eq!(reference_of("ATTACH 'datatable://sales' AS dt"), "sales");
|
||||
assert_eq!(reference_of("ATTACH 'datatable' AS dt"), "main");
|
||||
assert!(parse_attach_datatable("SELECT 1").is_none());
|
||||
// A stored name can contain `?`, so that is left to the workspace lookup to split.
|
||||
assert_eq!(reference_of("ATTACH 'datatable://a?b' AS dt"), "a?b");
|
||||
|
||||
// The key matches case-insensitively, as the `-- role` annotation does, and a query string
|
||||
// that does not parse is refused rather than attached under the default role.
|
||||
let parse = |q: &str| {
|
||||
windmill_common::workspaces::parse_datatable_ref(&reference_of(q))
|
||||
.map(|(name, role)| (name.to_string(), role.map(str::to_string)))
|
||||
};
|
||||
assert_eq!(
|
||||
parse("ATTACH 'datatable://sales?Role=analytics' AS dt").unwrap(),
|
||||
("sales".to_string(), Some("analytics".to_string()))
|
||||
);
|
||||
for malformed in [
|
||||
"ATTACH 'datatable://sales?role=' AS dt",
|
||||
"ATTACH 'datatable://sales?role=an;alytics' AS dt",
|
||||
"ATTACH 'datatable://sales?x=1&role=analytics' AS dt",
|
||||
] {
|
||||
assert!(parse(malformed).is_err(), "silently ignored: {malformed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ffi_error_unescapes_multiline_and_strips_quotes() {
|
||||
// Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ".
|
||||
|
||||
@@ -26,11 +26,9 @@ 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, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess,
|
||||
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
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::{
|
||||
@@ -682,36 +680,15 @@ pub async fn do_postgresql(
|
||||
} else {
|
||||
match pg_args.get("database").cloned() {
|
||||
Some(Value::String(db_str)) if db_str.starts_with("datatable://") => {
|
||||
let reference = db_str.trim_start_matches("datatable://");
|
||||
// The annotation wins: a generated query can carry a `?role=` in the reference it
|
||||
// was handed, but only the script's author writes the leading comment block.
|
||||
let annotated = SqlAnnotations::datatable_role(&query)?;
|
||||
let db_str = db_str.trim_start_matches("datatable://");
|
||||
Some(match conn {
|
||||
Connection::Http(client) => {
|
||||
let (name, uri_role) = parse_datatable_ref(reference)?;
|
||||
get_datatable_resource_from_agent_http(
|
||||
client,
|
||||
name,
|
||||
&job.workspace_id,
|
||||
annotated.as_deref().or(uri_role),
|
||||
&job.id,
|
||||
)
|
||||
.await?
|
||||
get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id)
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
let (name, uri_role) =
|
||||
parse_datatable_ref_for(db, &job.workspace_id, reference).await?;
|
||||
get_datatable_resource_from_db(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&name,
|
||||
annotated.as_deref().or(uri_role.as_deref()),
|
||||
DatatableAccess::PermissionedAs {
|
||||
permissioned_as: &job.permissioned_as,
|
||||
email: &job.permissioned_as_email,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1254,18 +1254,6 @@ async fn maybe_open_git_sync_deploy_pr(
|
||||
if row.marker.is_none() {
|
||||
return;
|
||||
}
|
||||
// Runtime Enterprise gate, like the poller: the toggles may have been set
|
||||
// while a license was active (or written directly), and this hook drives
|
||||
// GitHub API calls with the installation token.
|
||||
if !matches!(
|
||||
windmill_common::ee_oss::get_license_plan().await,
|
||||
windmill_common::ee_oss::LicensePlan::Enterprise
|
||||
) {
|
||||
tracing::warn!(
|
||||
"git sync PR: skipping PR creation for {workspace_id}: requires an Enterprise license"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(repo_path) = row.repo_path else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -55,8 +55,6 @@ export interface AppFile {
|
||||
tables?: string[];
|
||||
datatable?: string;
|
||||
schema?: string;
|
||||
/** The role the app uses each data table through, by data table name. */
|
||||
roles?: Record<string, string>;
|
||||
};
|
||||
// Mirrors granular ACLs on the raw_app path. Synced via /acls/* by
|
||||
// applyExtraPermsDiff — never through update_app_raw — so a perm-only
|
||||
|
||||
@@ -393,17 +393,10 @@ export async function pushWorkspaceSettings(
|
||||
|
||||
if (!deepEqual(localSettings.datatable, settings.datatable)) {
|
||||
log.debug(`Updating datatable config...`);
|
||||
const { stranded_references } = await wmill.editDataTableConfig({
|
||||
await wmill.editDataTableConfig({
|
||||
workspace,
|
||||
requestBody: { settings: localSettings.datatable ?? { datatables: {} } },
|
||||
});
|
||||
if (stranded_references?.length) {
|
||||
log.warn(
|
||||
`Removed data tables governed data tables in other workspaces, which no longer resolve: ${stranded_references
|
||||
.map((r) => `${r.workspace_id}/${r.datatable}`)
|
||||
.join(", ")}. A superadmin can point them somewhere else.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (localSettings.slack_command_script != settings.slack_command_script) {
|
||||
|
||||
Generated
+51
-32
@@ -1110,8 +1110,6 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1121,11 +1119,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -1906,8 +1901,6 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -1917,11 +1910,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -2796,8 +2786,6 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
/**
|
||||
* Create a SQL template function for PostgreSQL/datatable queries
|
||||
* @param name - Database/datatable name (default: "main")
|
||||
* @param opts.role - Connect as this data table role instead of the data table's default one.
|
||||
* Only meaningful on a data table under roles, and only for a role you are a tenant of.
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.datatable()
|
||||
@@ -2807,11 +2795,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}::int
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Read through a restricted role
|
||||
* let sql = wmill.datatable("main", { role: "analytics" })
|
||||
*/
|
||||
datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction
|
||||
datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
@@ -4420,13 +4405,10 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca
|
||||
#
|
||||
# Args:
|
||||
# name: Database name (default: "main")
|
||||
# role: Connect as this data table role instead of the data table's default one.
|
||||
# Only meaningful on a data table under roles, and only for a role you are a
|
||||
# tenant of.
|
||||
#
|
||||
# Returns:
|
||||
# DataTableClient instance
|
||||
def datatable(name: str = 'main', *, role: Optional[str] = None)
|
||||
def datatable(name: str = 'main')
|
||||
|
||||
# Get a DuckLake client for DuckDB queries.
|
||||
#
|
||||
@@ -4644,7 +4626,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
|
||||
#
|
||||
# @task(retry={"attempts": 3, "delay": 30, "multiplier": 2})
|
||||
# async def call_api(payload: dict): ...
|
||||
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
|
||||
# Create a task that dispatches to a separate Windmill script.
|
||||
#
|
||||
@@ -4657,7 +4639,7 @@ def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None,
|
||||
# @workflow
|
||||
# async def main():
|
||||
# data = await extract(url="https://...")
|
||||
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
|
||||
# Create a task that dispatches to a separate Windmill flow.
|
||||
#
|
||||
@@ -4670,7 +4652,7 @@ def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str]
|
||||
# @workflow
|
||||
# async def main():
|
||||
# result = await pipeline(input=data)
|
||||
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
|
||||
|
||||
# Decorator marking an async function as a workflow-as-code entry point.
|
||||
#
|
||||
@@ -4735,7 +4717,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.
|
||||
#
|
||||
@@ -5294,9 +5276,50 @@ tool, \`websearch\` for web search.
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
|
||||
agent through \`value.agent\`
|
||||
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
|
||||
fields to flow inputs instead — see below
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
|
||||
input, read by the agent.
|
||||
|
||||
**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs
|
||||
instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`)
|
||||
makes every field editable, or wire it field by field to fix some and expose others. A field the
|
||||
chat can write becomes a control in the composer — a provider picker, a model list, a thinking
|
||||
control — and a field left static is fixed, with no control drawn for it. \`kind\` is the one
|
||||
exception: the composer writes it only together with \`resource\`, since a provider is picked as a
|
||||
pair, so a \`kind\` input wired on its own stays askable under Configure inputs and nothing the run
|
||||
needs becomes unreachable.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "javascript",
|
||||
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\`
|
||||
references. A spread, a call or a computed key leaves the composer unable to tell which input
|
||||
feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object
|
||||
is read instead as that one input carrying every field
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
@@ -5775,8 +5798,6 @@ data:
|
||||
tables:
|
||||
- main/users # Table in public schema
|
||||
- main/app_schema:items # Table in specific schema
|
||||
roles: # Optional: the role the app uses each datatable through
|
||||
main: analyst
|
||||
\`\`\`
|
||||
|
||||
**Table reference formats:**
|
||||
@@ -5784,8 +5805,6 @@ data:
|
||||
- \`<datatable>/<table>\` — Specific table in public schema
|
||||
- \`<datatable>/<schema>:<table>\` — Table in specific schema
|
||||
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
The \`sql_to_apply/\` folder is for creating/modifying database tables during development.
|
||||
|
||||
@@ -12,6 +12,14 @@ Symbols, not line numbers, are cited: they drift less.
|
||||
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
|
||||
mints one for any non-job token but returns plain text, no redirect.
|
||||
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
|
||||
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items`
|
||||
removes an expired `token` row, the monitor emails the owner and raises a critical alert (if
|
||||
enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon"
|
||||
warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves
|
||||
the label, so a token the system mints for itself, whether from the backend or from the frontend
|
||||
through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change
|
||||
(keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix
|
||||
also goes into the SQL and Svelte mirrors that function's doc lists.
|
||||
- **Every superadmin route refuses a job token**: `require_super_admin`
|
||||
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
|
||||
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
|
||||
|
||||
@@ -65,10 +65,7 @@
|
||||
|
||||
# Misc
|
||||
libtool
|
||||
# Must not trail the server the dev database runs (postgres:18): pg_dump refuses a
|
||||
# server newer than itself by a major version, which takes out every data table
|
||||
# export, clone and fork-with-data.
|
||||
postgresql_18
|
||||
postgresql
|
||||
|
||||
# Build tooling
|
||||
pkg-config
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
let capability = $derived(
|
||||
provider && model
|
||||
? getReasoningCapability(provider, model)
|
||||
: { supported: false, levels: [], canDisable: false }
|
||||
: { supported: false, levels: [], canDisable: false, known: false }
|
||||
)
|
||||
|
||||
// The token that turns reasoning off on a model that reasons by default
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
|
||||
import type { DataTableTables } from '$lib/gen'
|
||||
import { sortArray } from '$lib/utils'
|
||||
import { Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import {
|
||||
dbSupportsSchemas,
|
||||
getLanguageByResourceType
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import { dbSupportsSchemas } from './apps/components/display/dbtable/utils'
|
||||
import DbManager from './DBManager.svelte'
|
||||
import DbWorkerTagPicker from './DbWorkerTagPicker.svelte'
|
||||
import MissingWorkerTagAlert from './jobs/MissingWorkerTagAlert.svelte'
|
||||
import {
|
||||
dbSchemaOpsWithPreviewScripts,
|
||||
dbTableOpsWithPreviewScripts,
|
||||
getDatabaseArg,
|
||||
getDbType,
|
||||
getDefaultDbTag,
|
||||
getDucklakeSchema
|
||||
@@ -23,11 +18,11 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SqlRepl from './SqlRepl.svelte'
|
||||
import SimpleAgTable from './SimpleAgTable.svelte'
|
||||
import type { DatatableRowAction, DbInput } from './dbTypes'
|
||||
import { schemaCacheKey } from './dbSchemaCache'
|
||||
import { type Snippet } from 'svelte'
|
||||
import type { DbInput } from './dbTypes'
|
||||
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
|
||||
|
||||
import type { PendingRowAction, SelectedTable } from './DBManager.svelte'
|
||||
import type { SelectedTable } from './DBManager.svelte'
|
||||
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
|
||||
import { resource } from 'runed'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
@@ -41,15 +36,7 @@
|
||||
hasReplResult?: boolean
|
||||
selectedSchemaKey?: string | undefined
|
||||
selectedTableKey?: string | undefined
|
||||
/** Every data table with its schemas and tables, for the left-pane tree. Undefined when
|
||||
* the manager is not on a data table, which drops the tree's top level. */
|
||||
datatableTree?: DataTableTables[]
|
||||
datatableTreeLoading?: boolean
|
||||
onSelectDatatable?: (datatable: string) => void
|
||||
onSelectRole?: (datatable: string, role: string) => void
|
||||
pendingAction?: PendingRowAction | undefined
|
||||
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
|
||||
canManageDatatable?: boolean
|
||||
dbSelector?: Snippet<[]>
|
||||
/** Enable multi-select mode with checkboxes in sidebar */
|
||||
multiSelectMode?: boolean
|
||||
/** Selected tables in multi-select mode */
|
||||
@@ -72,13 +59,7 @@
|
||||
hasReplResult = $bindable(false),
|
||||
selectedSchemaKey = $bindable(undefined),
|
||||
selectedTableKey = $bindable(undefined),
|
||||
datatableTree,
|
||||
datatableTreeLoading,
|
||||
onSelectDatatable,
|
||||
onSelectRole,
|
||||
pendingAction = $bindable(),
|
||||
onDatatableAction,
|
||||
canManageDatatable,
|
||||
dbSelector,
|
||||
multiSelectMode = false,
|
||||
selectedTables = $bindable([]),
|
||||
disabledTables = [],
|
||||
@@ -89,25 +70,33 @@
|
||||
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
|
||||
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(ws, input)])
|
||||
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(input)])
|
||||
|
||||
const outOfOrderModal = createAsyncConfirmationModal()
|
||||
|
||||
function getDbSchemasPath(input: DbInput): string {
|
||||
switch (input.type) {
|
||||
case 'database':
|
||||
return input.resourcePath
|
||||
case 'ducklake':
|
||||
return 'ducklake://' + input.ducklake
|
||||
}
|
||||
}
|
||||
|
||||
// Scope the shared `dbSchemas` cache by the acting workspace: a datatable of
|
||||
// the same name can exist in both the nav and the acting workspace, so the
|
||||
// bare resource path alone would let one workspace's schema be reused for the
|
||||
// other while DB operations target the acting one.
|
||||
function schemaCacheKey(input: DbInput): string {
|
||||
return `${ws}:${getDbSchemasPath(input)}`
|
||||
}
|
||||
|
||||
// Reported in place of the loading spinner: both queries run as jobs, so
|
||||
// anything from a bad connection to a tag no worker serves surfaces here
|
||||
// instead of leaving the manager spinning with no explanation. Each query
|
||||
// owns its slot so neither can clear the other's error on a refetch.
|
||||
let schemaError = $state<string | undefined>(undefined)
|
||||
let colDefsError = $state<string | undefined>(undefined)
|
||||
function emptySchemaFor(db: DbInput): DBSchema {
|
||||
return {
|
||||
lang: db.type === 'ducklake' ? 'ducklake' : getLanguageByResourceType(db.resourceType),
|
||||
schema: {},
|
||||
publicOnly: undefined,
|
||||
stringified: ''
|
||||
} as DBSchema
|
||||
}
|
||||
|
||||
let loadError = $derived(
|
||||
schemaError
|
||||
? { title: 'Could not load the database schema', message: schemaError }
|
||||
@@ -147,23 +136,14 @@
|
||||
const run = ++schemaRun
|
||||
schemaError = undefined
|
||||
if (!input) return
|
||||
const dbSchemasPath = schemaCacheKey(ws, input)
|
||||
const dbSchemasPath = schemaCacheKey(input)
|
||||
if (input.type == 'database') {
|
||||
let connection = input.resourcePath
|
||||
try {
|
||||
// The role'd reference, validated: an invalid role fails here rather than
|
||||
// reading the schema as the data table's default role.
|
||||
if (connection.startsWith('datatable://')) connection = getDatabaseArg(input).database!
|
||||
} catch (e) {
|
||||
schemaError = (e as Error)?.message ?? String(e)
|
||||
return
|
||||
}
|
||||
// Reported through a local, not `schemaError` directly, so a superseded
|
||||
// run's callback can't fail a load that already succeeded.
|
||||
let queryError: string | undefined
|
||||
const schema = await getDbSchemas(
|
||||
input.resourceType,
|
||||
connection,
|
||||
input.resourcePath,
|
||||
ws,
|
||||
(message: string) => (queryError = message),
|
||||
{ customTag: workerTag }
|
||||
@@ -243,23 +223,17 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- A load error replaces only the data pane: the tree, its role badge and menus, and the REPL
|
||||
stay usable, so another data table or role can be picked and the connection tried by hand.
|
||||
The tree then gets an empty schema: the cached one survives a failed refetch and would pass
|
||||
stale content off as what this connection reaches. -->
|
||||
{#snippet errorPane()}
|
||||
<!-- The error branch comes first on purpose: `dbSchema` is read from a cache that
|
||||
survives a failed refetch, so ordering it first would hide the failure behind
|
||||
stale content. -->
|
||||
{#if loadError}
|
||||
<div class="h-full w-full flex flex-col items-center justify-center gap-3 p-8">
|
||||
<div class="max-w-2xl w-full flex flex-col gap-3">
|
||||
<Alert type="error" title={loadError?.title ?? ''} size="xs">
|
||||
{loadError?.message}
|
||||
<Alert type="error" title={loadError.title} size="xs">
|
||||
{loadError.message}
|
||||
</Alert>
|
||||
<div class="self-start">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: RefreshCcw }}
|
||||
on:click={() => refresh()}
|
||||
>
|
||||
<Button size="xs" color="light" startIcon={{ icon: RefreshCcw }} on:click={() => refresh()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -273,12 +247,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if (loadError || dbSchema) && ws && input}
|
||||
{:else if dbSchema && ws && input}
|
||||
{@const _input = input}
|
||||
{@const dbType = getDbType(_input)}
|
||||
{@const shownSchema = loadError || !dbSchema ? emptySchemaFor(_input) : dbSchema}
|
||||
<Splitpanes horizontal>
|
||||
<Pane class="relative">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -303,11 +274,9 @@
|
||||
</div>
|
||||
<DbManager
|
||||
dbSupportsSchemas={dbSupportsSchemas(dbType)}
|
||||
databaseIsEmpty={!loadError &&
|
||||
!Object.values(shownSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
dbSchema={shownSchema}
|
||||
mainPane={loadError ? errorPane : undefined}
|
||||
colDefs={loadError ? undefined : colDefs.current}
|
||||
databaseIsEmpty={!Object.values(dbSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
{dbSchema}
|
||||
colDefs={colDefs.current}
|
||||
dbTableOpsFactory={({ colDefs, tableKey, whereClause }) =>
|
||||
dbTableOpsWithPreviewScripts({
|
||||
colDefs,
|
||||
@@ -337,15 +306,7 @@
|
||||
: undefined}
|
||||
{dbType}
|
||||
refresh={() => refresh()}
|
||||
{datatableTree}
|
||||
{datatableTreeLoading}
|
||||
{onSelectDatatable}
|
||||
{onSelectRole}
|
||||
workspace={ws}
|
||||
currentRole={input.type === 'database' ? input.role : undefined}
|
||||
bind:pendingAction
|
||||
{onDatatableAction}
|
||||
{canManageDatatable}
|
||||
{dbSelector}
|
||||
{onImport}
|
||||
bind:selectedSchemaKey
|
||||
bind:selectedTableKey
|
||||
@@ -368,12 +329,12 @@
|
||||
onSchemaChange={() => refresh()}
|
||||
placeholderTableName={sortArray(
|
||||
Object.keys(
|
||||
shownSchema.schema[
|
||||
'public' in shownSchema.schema
|
||||
dbSchema?.schema[
|
||||
'public' in dbSchema?.schema
|
||||
? 'public'
|
||||
: 'dbo' in shownSchema.schema
|
||||
: 'dbo' in dbSchema?.schema
|
||||
? 'dbo'
|
||||
: Object.keys(shownSchema.schema ?? {})?.[0]
|
||||
: Object.keys(dbSchema?.schema ?? {})?.[0]
|
||||
] ?? {}
|
||||
)
|
||||
)?.[0]}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense, superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService, type DataTableTables } from '$lib/gen'
|
||||
import { listUsableDatatableRoles } from './datatableUsableRoles'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } 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, Minimize, RefreshCcw, Upload } from 'lucide-svelte'
|
||||
import DBManagerContent from './DBManagerContent.svelte'
|
||||
import type { PendingRowAction } from './DBManager.svelte'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
|
||||
import {
|
||||
ADMIN_DATATABLE_ROLE,
|
||||
datatableNameTakesRole,
|
||||
type DatatableRowAction
|
||||
} from './dbTypes'
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
Expand,
|
||||
LoaderCircle,
|
||||
Minimize,
|
||||
RefreshCcw,
|
||||
Upload
|
||||
} from 'lucide-svelte'
|
||||
import DBManagerContent from './DBManagerContent.svelte'
|
||||
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { untrack } from 'svelte'
|
||||
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
|
||||
import ResourcePicker from './ResourcePicker.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -40,102 +41,31 @@
|
||||
// the editor that opened it (set via openDrawer), else the nav workspace.
|
||||
let ws = $derived(uriState.workspace ?? $workspaceStore)
|
||||
|
||||
// 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)
|
||||
|
||||
// Read once through primitives: the getters return values of a freshly parsed URL, which
|
||||
// changes on every table click, and the listings below must not refetch for that.
|
||||
const selectedDatatable = $derived(uriState.selectedDatatable)
|
||||
const selectedRole = $derived(uriState.selectedRole)
|
||||
|
||||
// Roles the caller may use, to settle the role before anything connects. Offering only
|
||||
// these is a convenience: the server refuses any other.
|
||||
const usableRoles = resource(
|
||||
() => [ws, selectedDatatable] as const,
|
||||
async ([workspace, datatable]) => {
|
||||
if (!workspace || !datatable) return undefined
|
||||
try {
|
||||
return {
|
||||
datatable,
|
||||
...(await listUsableDatatableRoles(workspace, datatable))
|
||||
}
|
||||
} catch (e) {
|
||||
// Never leave the drawer waiting on this: fall back to the
|
||||
// unpermissioned shape so it opens and the server picks the role.
|
||||
console.error('Failed to load datatable roles:', e)
|
||||
return { datatable, permissioned: false, roles: [], default_role: ADMIN_DATATABLE_ROLE }
|
||||
}
|
||||
// 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 resource keeps its previous value while refetching, and roles are per data
|
||||
// table: settling from the last one's answer would connect to the new data
|
||||
// table as a role it may not even have.
|
||||
const rolesOfCurrent = $derived(
|
||||
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
|
||||
)
|
||||
|
||||
// Nothing that connects runs until the role is settled: a first round sent without a role
|
||||
// would run — and cache — as whatever the server defaults to.
|
||||
const roleSettled = $derived(
|
||||
!uriState.isDatatableInput ||
|
||||
(rolesOfCurrent !== undefined &&
|
||||
(!rolesOfCurrent.permissioned ||
|
||||
rolesOfCurrent.roles.length === 0 ||
|
||||
selectedRole !== undefined ||
|
||||
// Its reference cannot name a role, so it connects as the default one.
|
||||
(selectedDatatable !== undefined && !datatableNameTakesRole(selectedDatatable))))
|
||||
)
|
||||
|
||||
// Make the role explicit before anything queries the data table, so the URL, the
|
||||
// cache and every migration the manager writes name it. A role already in the URL
|
||||
// is kept even when it is not usable: the server refuses it, visibly.
|
||||
$effect(() => {
|
||||
const roles = rolesOfCurrent
|
||||
if (
|
||||
!roles?.permissioned ||
|
||||
selectedRole !== undefined ||
|
||||
(selectedDatatable !== undefined && !datatableNameTakesRole(selectedDatatable))
|
||||
)
|
||||
return
|
||||
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
|
||||
if (effective) untrack(() => (uriState.selectedRole = effective))
|
||||
})
|
||||
|
||||
// Every data table with its schemas and tables, in one call: this is what the
|
||||
// left pane's tree navigates, so it has to cover the data tables the user is
|
||||
// not currently on, not just the selected one. The privileges it reports are
|
||||
// the connected role's, so the role picked on the open data table is part of
|
||||
// what is being asked. Gated on the drawer being open on a data table: this
|
||||
// reaches every data table's database in turn, and the component is mounted on
|
||||
// every logged-in page.
|
||||
let datatablesRun = 0
|
||||
const datatables = resource(
|
||||
() =>
|
||||
[
|
||||
open && uriState.isDatatableInput,
|
||||
ws,
|
||||
selectedDatatable,
|
||||
selectedRole,
|
||||
roleSettled
|
||||
] as const,
|
||||
async ([active, workspace, roleFor, role, settled]): Promise<DataTableTables[]> => {
|
||||
if (!active || !workspace) return []
|
||||
if (!settled) return untrack(() => datatables.current)
|
||||
const run = ++datatablesRun
|
||||
try {
|
||||
const result = await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
|
||||
// An answer for a selection that has since changed describes another role.
|
||||
return run === datatablesRun ? result : untrack(() => datatables.current)
|
||||
} catch (e) {
|
||||
console.error('Failed to load datatables:', e)
|
||||
return run === datatablesRun ? [] : untrack(() => datatables.current)
|
||||
}
|
||||
},
|
||||
{ initialValue: [] }
|
||||
const datatableItems = $derived(
|
||||
datatables.current.map((dt) => ({
|
||||
value: dt,
|
||||
label: dt
|
||||
}))
|
||||
)
|
||||
|
||||
// Refetch datatables when switching to a datatable input
|
||||
$effect(() => {
|
||||
if (uriState.isDatatableInput) {
|
||||
untrack(() => datatables.refetch())
|
||||
}
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
uriState.closeDrawer()
|
||||
dbManagerContent?.clearReplResult()
|
||||
@@ -148,10 +78,6 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -171,8 +97,6 @@
|
||||
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(
|
||||
@@ -192,49 +116,13 @@
|
||||
return toSourceIdentifier(input.resourcePath)
|
||||
}
|
||||
|
||||
// The tree's row menus act on the data table of the row that was clicked, which
|
||||
// is not necessarily the one currently open — so the target is set first and the
|
||||
// headless modals are keyed on it.
|
||||
let actionDatatable = $state<string | undefined>(undefined)
|
||||
let migrationsModal = $state<DataTableMigrationsButton | undefined>()
|
||||
let permissionsDrawer = $state<DataTablePermissionsButton | undefined>()
|
||||
|
||||
async function runDatatableAction(datatable: string, action: DatatableRowAction) {
|
||||
actionDatatable = datatable
|
||||
// Let the keyed block above mount against the new target before driving it.
|
||||
await tick()
|
||||
switch (action) {
|
||||
case 'migrations':
|
||||
migrationsModal?.open()
|
||||
break
|
||||
case 'roles':
|
||||
permissionsDrawer?.open()
|
||||
break
|
||||
case 'export':
|
||||
await handleExportSchema(`datatable://${datatable}`)
|
||||
break
|
||||
case 'import':
|
||||
importTarget = `datatable://${datatable}`
|
||||
importDrawerOpen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function refreshManager() {
|
||||
dbManagerContent?.refresh()
|
||||
dbManagerContent?.dbManager()?.dbTable()?.refresh()
|
||||
refreshRoles()
|
||||
}
|
||||
|
||||
/** 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()
|
||||
async function handleExportSchema() {
|
||||
const source = currentSourceIdentifier()
|
||||
if (!source || !ws) return
|
||||
try {
|
||||
exportResult = await WorkspaceService.exportPgSchema({
|
||||
@@ -249,7 +137,7 @@
|
||||
|
||||
async function handleImportDatabase() {
|
||||
if (!importSource || !ws) return
|
||||
const target = importTarget ?? currentSourceIdentifier()
|
||||
const target = currentSourceIdentifier()
|
||||
if (!target) return
|
||||
importLoading = true
|
||||
try {
|
||||
@@ -295,48 +183,52 @@
|
||||
noPadding
|
||||
id="db-manager-drawer"
|
||||
>
|
||||
{#if uriState.effectiveInput && ws && roleSettled}
|
||||
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
|
||||
{#if uriState.effectiveInput && ws}
|
||||
{#key uriState.selectedDatatable}
|
||||
<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) &&
|
||||
!!$enterpriseLicense &&
|
||||
!isCloudHosted()}
|
||||
onDatatableAction={runDatatableAction}
|
||||
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
|
||||
bind:hasReplResult
|
||||
bind:selectedSchemaKey={uriState.selectedSchema}
|
||||
bind:selectedTableKey={uriState.selectedTable}
|
||||
onImport={enableImportExport
|
||||
? (mode) => (
|
||||
(importTarget = undefined),
|
||||
(importDrawerOpen = true),
|
||||
(importBehavior = mode)
|
||||
)
|
||||
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
|
||||
: undefined}
|
||||
></DBManagerContent>
|
||||
>
|
||||
{#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>
|
||||
{/key}
|
||||
{/if}
|
||||
{#snippet actions()}
|
||||
<!-- A data table exports and imports from its row menu in the tree; a plain
|
||||
database has no tree row to hold them. -->
|
||||
{#if enableImportExport && !uriState.isDatatableInput}
|
||||
<Button startIcon={{ icon: Download }} onClick={() => handleExportSchema()}>Export</Button>
|
||||
<Button
|
||||
startIcon={{ icon: Upload }}
|
||||
onClick={() => ((importTarget = undefined), (importDrawerOpen = true))}
|
||||
>
|
||||
{#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}
|
||||
@@ -368,27 +260,6 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#if actionDatatable && ws}
|
||||
{#key actionDatatable}
|
||||
<DataTableMigrationsButton
|
||||
bind:this={migrationsModal}
|
||||
hideTrigger
|
||||
workspace={ws}
|
||||
datatable={actionDatatable}
|
||||
onSchemaChanged={refreshManager}
|
||||
/>
|
||||
{#if $enterpriseLicense && !isCloudHosted()}
|
||||
<DataTablePermissionsButton
|
||||
bind:this={permissionsDrawer}
|
||||
hideTrigger
|
||||
workspace={ws}
|
||||
datatable={actionDatatable}
|
||||
onSaved={refreshRoles}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
|
||||
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
|
||||
{#if exportResult}
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
{/if}
|
||||
{#if askingForConfirmation?.codeContent}
|
||||
<div
|
||||
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group min-w-0"
|
||||
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group"
|
||||
>
|
||||
<button
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface-hover"
|
||||
@@ -512,7 +512,9 @@
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
</button>
|
||||
<pre class="overflow-x-auto text-sm"><code>{askingForConfirmation.codeContent}</code></pre>
|
||||
<pre class="whitespace-pre-wrap text-sm"
|
||||
><code>{askingForConfirmation.codeContent}</code></pre
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import SelectDropdown from './select/SelectDropdown.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import { clickOutside } from '$lib/utils'
|
||||
|
||||
let {
|
||||
role,
|
||||
roles,
|
||||
onSelect
|
||||
}: {
|
||||
/** The role in effect, shown on the badge. */
|
||||
role: string
|
||||
/** The roles the caller may switch to. */
|
||||
roles: string[]
|
||||
onSelect: (role: string) => void
|
||||
} = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let anchorEl: HTMLSpanElement | undefined = $state()
|
||||
const items = $derived(roles.map((r) => ({ label: r, value: r })))
|
||||
|
||||
// The table picker's drawer opens at `disposables + 10000`, which the
|
||||
// dropdown's own z-index would sit under.
|
||||
const dropdownClass = 'z-[20000]'
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={anchorEl}
|
||||
class="relative flex min-w-0"
|
||||
use:clickOutside={{ onClickOutside: () => (open = false) }}
|
||||
>
|
||||
<Badge
|
||||
clickable
|
||||
color="gray"
|
||||
wrapperClass="min-w-0"
|
||||
class="min-w-0 gap-0.5 pl-2 pr-1 bg-surface-sunken hover:bg-surface-sunken text-primary
|
||||
transition-[filter,transform] hover:brightness-95 active:brightness-90 active:scale-[0.97]
|
||||
{open ? 'brightness-95' : ''}"
|
||||
onclick={(e) => {
|
||||
// The row underneath folds on click, and picking a role is not that.
|
||||
e.stopPropagation()
|
||||
open = !open
|
||||
}}
|
||||
>
|
||||
<!-- A long role name gives way rather than pushing the row's own actions
|
||||
past its right edge. -->
|
||||
<span class="truncate">{role}</span>
|
||||
<ChevronDown
|
||||
size={11}
|
||||
class="shrink-0 text-secondary transition-transform {open ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</Badge>
|
||||
<SelectDropdown
|
||||
processedItems={items}
|
||||
value={role}
|
||||
{open}
|
||||
listAutoWidth={false}
|
||||
class={dropdownClass}
|
||||
getInputRect={anchorEl && (() => anchorEl!.getBoundingClientRect())}
|
||||
onSelectValue={(item) => {
|
||||
open = false
|
||||
if (item.value !== role) onSelect(item.value)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
@@ -6,18 +6,8 @@
|
||||
import { joinSqlStatements, splitSqlRuns } from './sqlDdl'
|
||||
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
import { withMigrationRole } from './datatableMigrationRole'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
role
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
/** The role the editor runs as. The migration declares it, or it would run as admin. */
|
||||
role?: string
|
||||
} = $props()
|
||||
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
|
||||
|
||||
type Choice = 'run' | 'migrate' | 'cancel'
|
||||
|
||||
@@ -83,7 +73,7 @@
|
||||
function openMigrationModal(sql: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolveMigrationClosed = (created: boolean) => resolve(created)
|
||||
newMigrationModal?.open({ codeUp: withMigrationRole(sql, role) })
|
||||
newMigrationModal?.open({ codeUp: sql })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,11 +145,6 @@
|
||||
migrations rather than run ad-hoc. Create a migration for it instead?
|
||||
{/if}
|
||||
</p>
|
||||
{#if role}
|
||||
<p class="text-sm text-secondary">
|
||||
It will run as role <span class="font-mono">{role}</span>.
|
||||
</p>
|
||||
{/if}
|
||||
<pre
|
||||
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
|
||||
>{promptSql}</pre
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { Copy, Expand } from 'lucide-svelte'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
|
||||
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
|
||||
|
||||
interface Props {
|
||||
schema?: any | undefined
|
||||
@@ -28,6 +29,8 @@
|
||||
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
|
||||
// editors); used to qualify resource links.
|
||||
workspace?: string
|
||||
/** Given, the step header starts with a back control that calls it. */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +38,8 @@
|
||||
stepDetail = undefined,
|
||||
jobScriptHash = undefined,
|
||||
hideDefaultInputs = false,
|
||||
workspace = undefined
|
||||
workspace = undefined,
|
||||
onBack = undefined
|
||||
}: Props = $props()
|
||||
let ws = $derived(workspace ?? $workspaceStore)
|
||||
let codeViewer: Drawer | undefined = $state()
|
||||
@@ -104,57 +108,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if stepDetail == 'Input'}
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
{#if schema}
|
||||
<SchemaViewer {schema} />
|
||||
{:else}
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> No input schema </p>
|
||||
{/if}
|
||||
{:else if stepDetail == 'Result'}
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
|
||||
{:else if typeof stepDetail != 'string' && stepDetail.value}
|
||||
<!-- A direct child of the scrolling root: a sticky row can only hold within its parent's
|
||||
box, so wrapped with the path link below it would scroll away with that wrapper. -->
|
||||
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
|
||||
<div class="">
|
||||
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
|
||||
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
|
||||
<Badge color="indigo">
|
||||
{stepDetail.id}
|
||||
</Badge>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'font-semibold text-emphasis text-sm',
|
||||
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
|
||||
)}
|
||||
>
|
||||
{#if stepDetail.summary}
|
||||
{stepDetail.summary}
|
||||
{:else if stepDetail.value.type == 'identity'}
|
||||
Identity
|
||||
{:else if stepDetail.value.type == 'forloopflow'}
|
||||
For loop {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.value.type == 'branchall'}
|
||||
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
|
||||
{:else if stepDetail.value.type == 'branchone'}
|
||||
Run one branch
|
||||
{:else if stepDetail.value.type == 'flow'}
|
||||
Inner flow
|
||||
{:else if stepDetail.value.type == 'whileloopflow'}
|
||||
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
|
||||
{#if stepDetail.value.squash}(squash){/if}
|
||||
{:else if stepDetail.id === 'failure'}
|
||||
Error handler
|
||||
{:else if stepDetail.id === 'preprocessor'}
|
||||
Preprocessor
|
||||
{:else if stepDetail.value.type == 'rawscript'}
|
||||
Inline {stepDetail.value.language} script
|
||||
{:else if stepDetail.value.type == 'script'}
|
||||
Workspace script
|
||||
{:else if stepDetail.value.type == 'aiagent'}
|
||||
AI Agent
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if stepDetail.value.type == 'script'}
|
||||
<div class="pb-2">
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { Badge, Button } from './common'
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
/** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */
|
||||
stepDetail: FlowModule | string
|
||||
/** Given, the row starts with a back control; the caller decides where back leads. */
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let { stepDetail, onBack = undefined }: Props = $props()
|
||||
|
||||
const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail)
|
||||
// The error handler and the preprocessor are named by their role, not by an id badge.
|
||||
const showId = $derived(
|
||||
module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor'
|
||||
)
|
||||
|
||||
const title = $derived.by((): string => {
|
||||
if (typeof stepDetail === 'string') {
|
||||
if (stepDetail === 'Input') return 'Flow inputs'
|
||||
if (stepDetail === 'Result') return 'Result'
|
||||
return stepDetail
|
||||
}
|
||||
if (stepDetail.summary) return stepDetail.summary
|
||||
if (stepDetail.id === 'failure') return 'Error handler'
|
||||
if (stepDetail.id === 'preprocessor') return 'Preprocessor'
|
||||
const v = stepDetail.value
|
||||
switch (v?.type) {
|
||||
case 'identity':
|
||||
return 'Identity'
|
||||
case 'forloopflow':
|
||||
return (
|
||||
'For loop' +
|
||||
(v.parallel ? ' (parallel)' : '') +
|
||||
(v.skip_failures ? ' (skip failures)' : '') +
|
||||
(v.squash ? ' (squash)' : '')
|
||||
)
|
||||
case 'whileloopflow':
|
||||
return (
|
||||
'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '')
|
||||
)
|
||||
case 'branchall':
|
||||
return 'Run all branches' + (v.parallel ? ' (parallel)' : '')
|
||||
case 'branchone':
|
||||
return 'Run one branch'
|
||||
case 'flow':
|
||||
return 'Inner flow'
|
||||
case 'rawscript':
|
||||
return `Inline ${v.language} script`
|
||||
case 'script':
|
||||
return 'Workspace script'
|
||||
case 'aiagent':
|
||||
return 'AI Agent'
|
||||
default:
|
||||
return stepDetail.id
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- -top-2: the row pins at the scroll container's content edge, and FlowGraphViewerStep pads
|
||||
its root by that much, so at top-0 the body would show through the padding above the row. -->
|
||||
<div class="sticky -top-2 z-10 flex w-full items-center gap-2 bg-surface py-2">
|
||||
{#if onBack}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
title="Back to the flow graph"
|
||||
onclick={onBack}
|
||||
/>
|
||||
{/if}
|
||||
{#if showId && module}
|
||||
<Badge color="indigo">{module.id}</Badge>
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-emphasis" {title}>{title}</span>
|
||||
</div>
|
||||
@@ -473,6 +473,7 @@
|
||||
hideSidebar={true}
|
||||
path={$pathStore}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user