mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-19 00:02:28 +00:00
Compare commits
172
Commits
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.815.0"
|
||||
".": "1.814.0"
|
||||
}
|
||||
|
||||
@@ -1,39 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add a workspace toggle that adds its admins and developers to new forks ([#11215](https://github.com/windmill-labs/windmill/issues/11215)) ([9d335de](https://github.com/windmill-labs/windmill/commit/9d335de87a4dbaa51038d55afe8d980761dcdfaf))
|
||||
* add an instance setting to refuse a token in MCP URLs ([#11162](https://github.com/windmill-labs/windmill/issues/11162)) ([37e493a](https://github.com/windmill-labs/windmill/commit/37e493ae66ed5c000ecac492d60fc0fdf4bda71f))
|
||||
* **ai-chat:** render get_run through the run tools' card ([#11204](https://github.com/windmill-labs/windmill/issues/11204)) ([6f9c4dc](https://github.com/windmill-labs/windmill/commit/6f9c4dc29455d13b0e64af05c2d6aa8bd5ff4fd6))
|
||||
* **ai-chat:** tell the chat which kind of app it is looking at ([#11208](https://github.com/windmill-labs/windmill/issues/11208)) ([c8c06d8](https://github.com/windmill-labs/windmill/commit/c8c06d8f79774abf109192e71a8b6fc37c7937ba))
|
||||
* attach files to a flow chat message ([#11185](https://github.com/windmill-labs/windmill/issues/11185)) ([c4c9677](https://github.com/windmill-labs/windmill/commit/c4c9677982b75c63d98ebf85b1904e0c341ba957))
|
||||
* **auth:** 2 h login links and a click-to-sign-in page for emailed ones ([#11203](https://github.com/windmill-labs/windmill/issues/11203)) ([5639187](https://github.com/windmill-labs/windmill/commit/5639187fec6d517a72e82df49d63d7438301127c))
|
||||
* cap user token expiration with an instance setting ([#11159](https://github.com/windmill-labs/windmill/issues/11159)) ([9320312](https://github.com/windmill-labs/windmill/commit/9320312eac56f944c4d31504601293ab4e816ccc))
|
||||
* flow chat job-backed detail, smooth streaming and answer chrome ([#11186](https://github.com/windmill-labs/windmill/issues/11186)) ([e2a91ca](https://github.com/windmill-labs/windmill/commit/e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35))
|
||||
* put a data table's connection under Postgres roles ([#11020](https://github.com/windmill-labs/windmill/issues/11020)) ([0e807fb](https://github.com/windmill-labs/windmill/commit/0e807fb1dd80d7536ec144cd49445abc7961e504))
|
||||
* render an AI agent result as its answer, not as raw JSON ([#11051](https://github.com/windmill-labs/windmill/issues/11051)) ([a089928](https://github.com/windmill-labs/windmill/commit/a08992834d45d0211336f4fc32c3421646ca47c5))
|
||||
* support $flow_expr[...] dynamic tags on flow steps ([#11170](https://github.com/windmill-labs/windmill/issues/11170)) ([48f0025](https://github.com/windmill-labs/windmill/commit/48f00259c5e7361d3553dbcb809461e1cde96f8f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** inline only the package version, not the whole package.json ([#11191](https://github.com/windmill-labs/windmill/issues/11191)) ([813e486](https://github.com/windmill-labs/windmill/commit/813e486e166ac6215364817a7732b66f8dc1d463))
|
||||
* keep instance groups when editing auto-invite ([#11217](https://github.com/windmill-labs/windmill/issues/11217)) ([df61dea](https://github.com/windmill-labs/windmill/commit/df61dea5fa8b18d1e0044dc0db6702b053d5119f))
|
||||
* key the large root font size on screen width, not window width ([#11216](https://github.com/windmill-labs/windmill/issues/11216)) ([3b4e13d](https://github.com/windmill-labs/windmill/commit/3b4e13d1c564c6195e30b55b0671f7533e3ce408))
|
||||
* re-encrypt git sync secrets on workspace key rotation ([#11218](https://github.com/windmill-labs/windmill/issues/11218)) ([f0d66a4](https://github.com/windmill-labs/windmill/commit/f0d66a42eba27502767423af0c08b2ca1f16e8df))
|
||||
* re-point cloned fork identities that name nobody in the fork ([#11161](https://github.com/windmill-labs/windmill/issues/11161)) ([9690c44](https://github.com/windmill-labs/windmill/commit/9690c4462cf264a5577b87d07d465b5442b4e09d))
|
||||
* register the job token with the sensitive log masking system ([#10943](https://github.com/windmill-labs/windmill/issues/10943)) ([53afecd](https://github.com/windmill-labs/windmill/commit/53afecd4588247bc1812d3e68a30db1f3c3b2724))
|
||||
* show the New menu's description panel only on hover ([#11199](https://github.com/windmill-labs/windmill/issues/11199)) ([ecd0a6c](https://github.com/windmill-labs/windmill/commit/ecd0a6c77bc3a057b8072dbb0aca731e8bd3d882))
|
||||
* stop picker listing a draft twice when drafts share a friendly path ([#11214](https://github.com/windmill-labs/windmill/issues/11214)) ([72507d5](https://github.com/windmill-labs/windmill/commit/72507d52a021de8101794ee8f4a3392be1ba20c7))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* count completed jobs on the v2_job_completed index with a timeout ([#11211](https://github.com/windmill-labs/windmill/issues/11211)) ([d1a2536](https://github.com/windmill-labs/windmill/commit/d1a25360b070994a31fb033707dbfde1fb553939))
|
||||
|
||||
## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17)
|
||||
|
||||
|
||||
|
||||
@@ -49,16 +49,12 @@ export interface BenchmarkWorkspaceFlow {
|
||||
export interface BenchmarkWorkspaceApp {
|
||||
path: string
|
||||
summary: string
|
||||
/** Defaults to true. Set false for a drag-and-drop app, which the chat can list
|
||||
* and read but has no tool to edit — its value is a grid, not files. */
|
||||
rawApp?: boolean
|
||||
value: {
|
||||
files?: Record<string, string>
|
||||
runnables?: Record<string, unknown>
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, unknown>
|
||||
data?: unknown
|
||||
policy?: unknown
|
||||
custom_path?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,7 +994,7 @@ function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp {
|
||||
extra_perms: {},
|
||||
edited_at: BENCHMARK_TIMESTAMP,
|
||||
execution_mode: 'viewer',
|
||||
raw_app: app.rawApp ?? true
|
||||
raw_app: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,7 +1012,7 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
|
||||
execution_mode: 'viewer',
|
||||
extra_perms: {},
|
||||
custom_path: app.value.custom_path as string | undefined,
|
||||
raw_app: app.rawApp ?? true
|
||||
raw_app: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2611,40 +2611,3 @@
|
||||
judgeChecklist:
|
||||
- runs the existing script rather than rewriting it
|
||||
- passes the GitHub resource as the bare string $res:f/evals/global/github_main
|
||||
|
||||
- id: global-drag-and-drop-app-not-editable
|
||||
prompt: |-
|
||||
Add a refresh button to the ops console app, and the same to the sales board app.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json
|
||||
runtime:
|
||||
maxTurns: 12
|
||||
validate:
|
||||
# One request, two apps, only one of them editable: the code app must come back with a
|
||||
# draft and the drag-and-drop one must not. Refusing both, or editing both, fails here —
|
||||
# which is what makes this a test of the distinction rather than of caution.
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: app
|
||||
path: f/evals/global/ops_console
|
||||
forbiddenDrafts:
|
||||
- type: app
|
||||
path: f/evals/global/sales_board
|
||||
toolExpect:
|
||||
# Deliberately not constraining write_app_file/patch_app_file by argument: an entry there
|
||||
# fails when its tool was never called, so naming both would fail on whichever the model
|
||||
# did not pick. The draft assertions above cover the same ground, tool-agnostically.
|
||||
forbiddenToolsUsed:
|
||||
- init_app
|
||||
- deploy_workspace_item
|
||||
- delete_app_file
|
||||
- delete_app_runnable
|
||||
assistantExpect:
|
||||
# A refusal leaves no draft for the judge to read, so the explanation is checked here.
|
||||
# Only the app kind: substring tests cannot see paraphrase, and every wording of "I can't
|
||||
# edit it" defeats a fixed list.
|
||||
requiredMentionsAnyOf:
|
||||
- - drag-and-drop
|
||||
- drag and drop
|
||||
- low-code
|
||||
- no-code
|
||||
skipJudge: true
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"is_admin": true,
|
||||
"folders": ["evals"],
|
||||
"folders_read": ["evals"]
|
||||
},
|
||||
"workspace": {
|
||||
"apps": [
|
||||
{
|
||||
"path": "f/evals/global/sales_board",
|
||||
"summary": "Sales board",
|
||||
"rawApp": false,
|
||||
"value": {
|
||||
"grid": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "f/evals/global/ops_console",
|
||||
"summary": "Ops console",
|
||||
"value": {
|
||||
"files": {
|
||||
"/App.tsx": "export default function App() {\n\treturn <div>Ops console</div>\n}\n"
|
||||
},
|
||||
"runnables": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft AS d\n SET path = $3::text,\n value = (\n SELECT CASE\n WHEN s.clean -> 'path' = to_jsonb($2::text)\n OR s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n s.clean\n || CASE WHEN s.clean -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE d.value\n END\n FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),\n chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s\n )\n WHERE d.workspace_id = $1\n AND d.path = $2::text\n AND d.typ::text = ANY($4::text[])",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934"
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "base",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft\n SET path = $3,\n -- Both path keys, not just the typed one: the editors mirror the\n -- typed path into the other while it differs from the row's path,\n -- and the loaders prefer the mirror — left naming the old location\n -- it un-does this move on the next save. `create_missing = false`\n -- on both, so a draft carrying only one keeps only one.\n value = to_json(\n jsonb_set(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n ),\n ARRAY[$8::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])\n -- Of this kind only the caller's own row and the legacy one collide:\n -- teammates' drafts of one item share its path by design, but a deploy\n -- there wipes those two together, so a second would discard edits the\n -- caller never saw. The other app kind is a different item on the same\n -- deployed path, so it collides whoever owns it.\n AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)\n ))\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108"
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a\n JOIN LATERAL (\n SELECT av2.id, COALESCE(v.ord, 0) AS ord\n FROM app_version av2\n LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id\n WHERE av2.app_id = a.id\n ORDER BY ord DESC, av2.id DESC\n LIMIT $3 OFFSET $4\n ) page ON TRUE\n JOIN app_version av ON av.id = page.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2\n ORDER BY page.ord DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "app_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text\n FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])\n AND m.new_path = $3 AND m.email IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM draft_move o\n WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ\n AND o.old_path = m.old_path AND o.email = $5::text\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET auto_invite = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "add_admins_and_developers_to_forks",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft_move SET new_path = $4\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,5 +55,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
|
||||
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft_move src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.old_path = dest.old_path\n AND src.typ = dest.typ)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true\n AND ($2::varchar IS NULL OR workspace_id = $2))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8"
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3::text, $4,\n CASE WHEN $10::bool\n THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(\n 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END,\n 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END)))\n ELSE $5::text::json\n END,\n COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = CASE\n WHEN NOT $10::bool\n OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0\n THEN EXCLUDED.value\n ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')\n || jsonb_strip_nulls(jsonb_build_object(\n 'path', to_jsonb(draft.value) -> 'path',\n 'draft_path', to_jsonb(draft.value) -> 'draft_path')))\n END,\n created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING path, created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Timestamptz",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14"
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version\n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2\n ORDER BY flow_version.created_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5"
|
||||
}
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled, usr.is_service_account\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
|
||||
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -52,11 +52,6 @@
|
||||
"ordinal": 9,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "is_service_account",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -74,9 +69,8 @@
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e"
|
||||
"hash": "88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5"
|
||||
}
|
||||
+3
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + make_interval(days => $1)\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
|
||||
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -25,9 +25,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -36,5 +34,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc"
|
||||
"hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3"
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5"
|
||||
}
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC LIMIT 1",
|
||||
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,11 +17,6 @@
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -33,9 +28,8 @@
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb"
|
||||
"hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET auto_invite = (COALESCE(auto_invite, '{}'::jsonb) - 'domain') || $1::jsonb WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb"
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n -- The guard's own predicate, ordered own row, then legacy, then another\n -- user's other-kind row: each needs a different remedy, and a bare\n -- LIMIT 1 would name an arbitrary one.\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[])\n AND (typ <> $2 OR email = $4 OR email IS NULL)\n ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email IS NULL) as \"at_target_legacy!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "at_target",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "at_target_own!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "at_target_legacy!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "poisoned!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "legacy!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
|
||||
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995"
|
||||
"hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142"
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a JOIN app_version av\n ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "app_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee"
|
||||
}
|
||||
+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 AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\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": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99"
|
||||
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
|
||||
}
|
||||
+3
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -177,11 +177,6 @@
|
||||
"ordinal": 34,
|
||||
"name": "guest_jwt_jwks_url",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "add_admins_and_developers_to_forks",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -224,9 +219,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363"
|
||||
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft_move SET email = $1 WHERE email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b"
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT m.new_path FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3\n AND (m.email IS NULL OR m.email = $4)\n AND NOT EXISTS (\n SELECT 1 FROM draft d\n WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3\n AND d.email IS NOT DISTINCT FROM $4\n )\n ORDER BY m.email IS NULL\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "new_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github",
|
||||
"data_pipeline",
|
||||
"trigger_amqp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2"
|
||||
}
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -45,21 +45,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "add_admins_and_developers_to_forks",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"ordinal": 9,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"ordinal": 10,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
@@ -78,11 +73,10 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a"
|
||||
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "deployment_msg",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780"
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT d.email IS NULL as \"legacy!\", COALESCE(u.username, p.username) as username\n FROM draft d\n LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email\n LEFT JOIN password p ON p.email = d.email AND p.super_admin = true\n WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])\n -- A row a move already brought here from the very path being renamed is this\n -- rename's own draft, not an item in its way: the record says which owners\n -- it moved (all of them for an item move, one for a draft-only move). Any\n -- other row is a second item and still collides.\n AND NOT EXISTS (\n SELECT 1 FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])\n AND m.old_path = $4 AND m.new_path = $2\n AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)\n )\n ORDER BY 2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "legacy!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46"
|
||||
}
|
||||
Generated
+86
-85
@@ -2311,9 +2311,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.7"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -5055,9 +5055,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.13"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
@@ -5497,9 +5497,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.10"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
|
||||
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
@@ -14793,7 +14793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14881,7 +14881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14915,7 +14915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14928,7 +14928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15068,7 +15068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15091,7 +15091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15108,7 +15108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15134,7 +15134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15144,7 +15144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15161,7 +15161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15183,7 +15183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15206,7 +15206,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15222,7 +15222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15244,7 +15244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15266,7 +15266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15280,7 +15280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15315,7 +15315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15340,7 +15340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15368,7 +15368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15390,7 +15390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15410,7 +15410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15448,7 +15448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15477,7 +15477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15489,7 +15489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15513,7 +15513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15527,7 +15527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15562,7 +15562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15576,7 +15576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15595,7 +15595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15653,6 +15653,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"pkcs1",
|
||||
"postgres-native-tls 0.5.3",
|
||||
"postgres-protocol",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
@@ -15702,7 +15703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -15722,7 +15723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15739,7 +15740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15766,7 +15767,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15783,7 +15784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15799,7 +15800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15820,7 +15821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15851,7 +15852,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15876,7 +15877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15911,7 +15912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15929,7 +15930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15938,7 +15939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15950,7 +15951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15962,7 +15963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15974,7 +15975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15986,7 +15987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15998,7 +15999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16009,7 +16010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16020,7 +16021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16032,7 +16033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16043,7 +16044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16065,7 +16066,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16077,7 +16078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16091,7 +16092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16108,7 +16109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16121,7 +16122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16133,7 +16134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16151,7 +16152,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16167,7 +16168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16183,7 +16184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16197,7 +16198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16236,7 +16237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16276,7 +16277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16287,7 +16288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16322,7 +16323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16346,7 +16347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16379,7 +16380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16406,7 +16407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16439,7 +16440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16459,7 +16460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16493,7 +16494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16529,7 +16530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16552,7 +16553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16576,7 +16577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16600,7 +16601,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16635,7 +16636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16663,7 +16664,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16688,7 +16689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -16707,7 +16708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16825,7 +16826,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -624,6 +624,7 @@ 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 @@
|
||||
bc3ef08c8e4233508c023e6ee847a3cd0b8be43b
|
||||
220301c27272b6c9e38401884c45cd959bd02a09
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE draft DROP COLUMN base;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- The version a draft forked from, as one opaque text id whatever the kind: a
|
||||
-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that
|
||||
-- was never forked from a deploy and for kinds that keep no lineage.
|
||||
ALTER TABLE draft ADD COLUMN base TEXT;
|
||||
|
||||
-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and
|
||||
-- get their base on their next save.
|
||||
UPDATE draft SET base = CASE typ::text
|
||||
WHEN 'script' THEN value ->> 'parent_hash'
|
||||
WHEN 'flow' THEN value ->> 'version_id'
|
||||
ELSE value ->> 'parent_version'
|
||||
END
|
||||
WHERE typ::text IN ('script', 'flow', 'app', 'raw_app')
|
||||
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0;
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE draft_move;
|
||||
@@ -1,17 +0,0 @@
|
||||
-- Where an item's drafts went when it moved, so a draft save still addressed to the
|
||||
-- old path (an editor left open across the move, a chat, the CLI) lands on the moved
|
||||
-- draft instead of starting a new item there. `email` NULL records a deployed item's
|
||||
-- move and applies to every user; set, it records that user's draft-only move.
|
||||
CREATE TABLE draft_move (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
typ DRAFT_KIND NOT NULL,
|
||||
old_path VARCHAR(255) NOT NULL,
|
||||
new_path VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE INDEX draft_move_old_path_idx ON draft_move (workspace_id, typ, old_path);
|
||||
|
||||
GRANT ALL ON draft_move TO windmill_user;
|
||||
GRANT ALL ON draft_move TO windmill_admin;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false;
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.815.0"
|
||||
version = "1.814.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+2
-7
@@ -52,8 +52,7 @@ use windmill_common::{
|
||||
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
|
||||
NO_DEFAULT_MAVEN_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
|
||||
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
@@ -127,8 +126,7 @@ use windmill_worker::{
|
||||
|
||||
use crate::monitor::{
|
||||
initial_load, load_concurrency_key_max_queued, load_disable_password_login,
|
||||
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
|
||||
load_mcp_disable_token_query_param, load_metrics_debug_enabled,
|
||||
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled,
|
||||
load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides,
|
||||
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces,
|
||||
load_workspace_fairness_duration_secs, load_workspace_fairness_enabled,
|
||||
@@ -2166,9 +2164,6 @@ async fn process_notify_event(
|
||||
DISABLE_PASSWORD_LOGIN_SETTING => {
|
||||
load_disable_password_login(db).await;
|
||||
}
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
|
||||
load_mcp_disable_token_query_param(db).await;
|
||||
}
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
|
||||
|
||||
+3
-34
@@ -45,10 +45,7 @@ use windmill_common::otel_oss::{
|
||||
use windmill_common::{
|
||||
agent_workers::DECODED_AGENT_TOKEN,
|
||||
apps::APP_WORKSPACED_ROUTE,
|
||||
auth::{
|
||||
create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs,
|
||||
TOKEN_EXPIRY_WARNING_DAYS,
|
||||
},
|
||||
auth::{create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs},
|
||||
ee_oss::CriticalErrorChannel,
|
||||
email_oss::send_email_if_possible,
|
||||
error,
|
||||
@@ -65,7 +62,6 @@ use windmill_common::{
|
||||
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
|
||||
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
|
||||
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
|
||||
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
|
||||
@@ -289,15 +285,6 @@ pub async fn initial_load(
|
||||
);
|
||||
|
||||
if let Some(db) = conn.as_sql() {
|
||||
// Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes
|
||||
// with `server_mode` false and would otherwise never read this at all. That mode
|
||||
// joins no monitor loop, so there — as for every global setting, `base_url`
|
||||
// included — this pass is the only read, and a change lands on restart.
|
||||
pass.setting(
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
|
||||
false,
|
||||
|v| async move { apply_mcp_disable_token_query_param(v) },
|
||||
);
|
||||
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
|
||||
apply_tag_per_workspace_enabled(v)
|
||||
});
|
||||
@@ -1627,23 +1614,6 @@ pub fn apply_disable_password_login(value: Option<serde_json::Value>) {
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn load_mcp_disable_token_query_param(db: &DB) {
|
||||
match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await {
|
||||
Ok(v) => apply_mcp_disable_token_query_param(v),
|
||||
Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
|
||||
match value {
|
||||
Some(serde_json::Value::Bool(t)) => {
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed)
|
||||
}
|
||||
None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed),
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
|
||||
struct LogFile {
|
||||
file_path: String,
|
||||
hostname: String,
|
||||
@@ -2228,7 +2198,7 @@ async fn cleanup_scheduled_job_deletions(db: &Pool<Postgres>) {
|
||||
}
|
||||
|
||||
pub async fn check_expiring_tokens(db: &DB) {
|
||||
// Find tokens expiring within the warning window that still have a pending notification row.
|
||||
// Find tokens expiring within 7 days that still have a pending notification row.
|
||||
// The notification table stores token_hash (not plaintext) so the join works
|
||||
// even after the hash migration makes token.token nullable.
|
||||
let expiring_tokens_r = sqlx::query_as!(
|
||||
@@ -2237,9 +2207,8 @@ pub async fn check_expiring_tokens(db: &DB) {
|
||||
USING token t
|
||||
WHERE n.token_hash = t.token_hash
|
||||
AND n.expiration > now()
|
||||
AND n.expiration <= now() + make_interval(days => $1)
|
||||
AND n.expiration <= now() + interval '7 days'
|
||||
RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
|
||||
TOKEN_EXPIRY_WARNING_DAYS,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
@@ -234,7 +234,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
//! The deployed order of an app's versions is the order they were appended to
|
||||
//! `app.versions`, not the order of their `created_at`.
|
||||
//!
|
||||
//! `app_version.created_at` defaults to `now()`, which in Postgres is the
|
||||
//! transaction's start time, while the append happens under the app row's lock.
|
||||
//! Two deploys that overlap therefore land in one order and carry timestamps in
|
||||
//! the other. The head the editor guards against, and the sequence the diff
|
||||
//! picker numbers, both have to follow the array.
|
||||
//!
|
||||
//! Users from the `base` fixture: test-user (admin, token SECRET_TOKEN).
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const TOKEN: &str = "SECRET_TOKEN";
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_app_head_follows_the_append_order_not_the_timestamps(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let ws = format!(
|
||||
"http://localhost:{}/api/w/test-workspace",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let res = client
|
||||
.post(format!("{ws}/apps/create"))
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/order_app",
|
||||
"summary": "ordered",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "publisher", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(res.status().is_success(), "{}", res.text().await?);
|
||||
|
||||
let first: i64 = sqlx::query_scalar(
|
||||
"SELECT versions[array_upper(versions, 1)] FROM app
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
// The overlapping deploy: appended after `first`, so it is the version that
|
||||
// landed, but stamped before it, so a timestamp sort puts it underneath.
|
||||
let second: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||||
SELECT app_id, value, 'racer', created_at - interval '1 hour', raw_app
|
||||
FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(first)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE app SET versions = array_append(versions, $1::bigint)
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
|
||||
)
|
||||
.bind(second)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let head: serde_json::Value = client
|
||||
.get(format!(
|
||||
"{ws}/apps/get_latest_version/u/test-user/order_app"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head["version"], second,
|
||||
"the head is the version appended last, not the newest timestamp: {head}"
|
||||
);
|
||||
|
||||
let history: Vec<serde_json::Value> = client
|
||||
.get(format!("{ws}/apps/history/p/u/test-user/order_app"))
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let listed: Vec<i64> = history
|
||||
.iter()
|
||||
.map(|v| v["version"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
vec![second, first],
|
||||
"the history lists in deployed order, so it leads with the head"
|
||||
);
|
||||
|
||||
// Enough versions that a page cannot hold them, so "asked for nothing" and "asked for
|
||||
// a page" are visibly different answers.
|
||||
let mut appended = vec![second, first];
|
||||
for _ in 0..24 {
|
||||
let extra: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||||
SELECT app_id, value, 'bulk', created_at, raw_app FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(first)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE app SET versions = array_append(versions, $1::bigint)
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
|
||||
)
|
||||
.bind(extra)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
appended.insert(0, extra);
|
||||
}
|
||||
|
||||
let versions_at = |query: &str| {
|
||||
let url = format!("{ws}/apps/history/p/u/test-user/order_app{query}");
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let rows: Vec<serde_json::Value> = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {TOKEN}"))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>(
|
||||
rows.iter()
|
||||
.map(|v| v["version"].as_i64().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// The deployment-history panel and the CLI read this endpoint without paging, so
|
||||
// asking for no page has to keep answering with the whole history.
|
||||
assert_eq!(
|
||||
versions_at("").await?,
|
||||
appended,
|
||||
"an unpaginated request still answers whole"
|
||||
);
|
||||
assert_eq!(
|
||||
versions_at("?per_page=10").await?,
|
||||
appended[..10],
|
||||
"a page holds what was asked for, newest first"
|
||||
);
|
||||
assert_eq!(
|
||||
versions_at("?per_page=10&page=2").await?,
|
||||
appended[10..20],
|
||||
"the next page carries on where the first left off, skipping nothing"
|
||||
);
|
||||
// A page past the end runs off it rather than overflowing into one. (The clamp on an
|
||||
// asked-for size is pinned where it lives, in `paginate_optional`'s own test.)
|
||||
assert!(
|
||||
versions_at("?per_page=10&page=99999999")
|
||||
.await?
|
||||
.is_empty(),
|
||||
"a page past the end is empty"
|
||||
);
|
||||
// Naming only the page still asks for a page, rather than an offset into a listing
|
||||
// sized to hold everything — which would answer nothing at all.
|
||||
assert_eq!(
|
||||
versions_at("?page=1").await?,
|
||||
appended,
|
||||
"a first page with no size named holds the history"
|
||||
);
|
||||
|
||||
// A version that never entered the deployed sequence — one a restore or a fork copy
|
||||
// could leave behind — still has to be reachable, after the ones that did.
|
||||
let stray: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
|
||||
SELECT app_id, value, 'stray', created_at, raw_app FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(first)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let mut with_stray = appended.clone();
|
||||
with_stray.push(stray);
|
||||
assert_eq!(
|
||||
versions_at("").await?,
|
||||
with_stray,
|
||||
"a version outside app.versions is listed, and after the deployed sequence"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -521,8 +521,10 @@ def main():
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deployed: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}");
|
||||
assert_eq!(
|
||||
res.text().await.unwrap(),
|
||||
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
|
||||
);
|
||||
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
in_test_worker(&db, completed.next(), port).await;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Moving a draft must carry both of its path keys.
|
||||
//!
|
||||
//! A draft value holds a typed path and a mirror the editors keep beside it while
|
||||
//! it differs from the row's path (`path`/`draft_path`; which is which depends on
|
||||
//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed
|
||||
//! key left the mirror naming the old location: reopening the item restored the
|
||||
//! old path, and the next autosave wrote it back — undoing the move silently.
|
||||
//!
|
||||
//! This pins `move_draft`, including that a draft with no mirror never gains one.
|
||||
//! A deploy's carry follows the keys only where they still name the old path
|
||||
//! (`drafts_save_follows_move` pins that).
|
||||
|
||||
use serde_json::Value;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result<String> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&serde_json::json!({ "new_path": to }))
|
||||
.send()
|
||||
.await?;
|
||||
// Asserted here so a rejected move fails as itself, rather than as a JSON
|
||||
// parse error in the read-back below.
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert!(status.is_success(), "move {from} -> {to} failed: {body}");
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// The stored draft value at `path`, read back through the API so this test needs
|
||||
/// no `sqlx::query!` (which would want an offline cache entry of its own).
|
||||
async fn value_at(port: u16, path: &str) -> anyhow::Result<Value> {
|
||||
let body: Value = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(body
|
||||
.get("value")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("no draft at {path}: {body}")))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_move_mirror"))]
|
||||
async fn test_move_carries_both_path_keys(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?;
|
||||
move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?;
|
||||
|
||||
// The mirror follows: left at `u/test-user/friendly` it would win at load and
|
||||
// walk the item back there.
|
||||
let moved = value_at(port, "u/test-user/renamed").await?;
|
||||
assert_eq!(moved["path"], "u/test-user/renamed");
|
||||
assert_eq!(moved["draft_path"], "u/test-user/renamed");
|
||||
|
||||
// A draft that never had a mirror must not be given one.
|
||||
let plain = value_at(port, "u/test-user/plain2").await?;
|
||||
assert_eq!(plain["path"], "u/test-user/plain2");
|
||||
assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
//! A rename onto a path that already holds a draft is refused.
|
||||
//!
|
||||
//! Nothing deployed can sit at a rename's destination (the deploy conflicts on
|
||||
//! that), but a draft can: a never-deployed item, or a draft left on an archived
|
||||
//! script. Moving onto it would merge two items or strand a row, so the rename
|
||||
//! itself fails, in its own transaction, and the source stays deployed. The
|
||||
//! destination draft here is the deployer's own, which is the same collision.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Hex form of script hash 7010, the way the API takes a parent hash.
|
||||
const HEAD_HASH: &str = "0000000000001b62";
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_rename_onto_a_draft_is_refused(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/mvtaken_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 400, "rename onto a draft was not refused: {body}");
|
||||
assert!(
|
||||
body.contains("already has a draft"),
|
||||
"unexpected refusal: {body}"
|
||||
);
|
||||
|
||||
// The whole deploy rolled back: the source is still the live head, and the
|
||||
// draft at the destination is untouched.
|
||||
let head: Value = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/get/p/u/test-user/mvtaken_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(head["hash"], HEAD_HASH, "source was replaced: {head}");
|
||||
assert_eq!(head["archived"], false, "source was archived: {head}");
|
||||
|
||||
let draft: Value = client
|
||||
.get(format!("http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/mvtaken_b"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
draft["value"]["summary"], "B",
|
||||
"destination draft changed: {draft}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together
|
||||
/// with the caller's own row, so a move that parks a second draft beside it would discard
|
||||
/// edits the caller never saw. Only an admin can clear it, so the refusal says so.
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_draft_move_refuses_a_legacy_destination(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/mvtaken_legacy" }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}");
|
||||
assert!(
|
||||
body.contains("legacy workspace draft") && body.contains("workspace admin"),
|
||||
"the refusal did not point at the one remedy: {body}"
|
||||
);
|
||||
|
||||
// Both rows stayed where they were: the caller's own, and the legacy one the list
|
||||
// synthesizes under the caller's name.
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let mut at = list
|
||||
.iter()
|
||||
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
|
||||
.filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy"))
|
||||
.collect::<Vec<_>>();
|
||||
at.sort();
|
||||
assert_eq!(
|
||||
at,
|
||||
vec![
|
||||
("script", "u/test-user/mvtaken_b"),
|
||||
("script", "u/test-user/mvtaken_legacy")
|
||||
],
|
||||
"{list:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A classic app and a raw app deploy into the same table, so a draft-only move onto
|
||||
/// the other kind's draft must be refused: deploying either path afterwards deletes
|
||||
/// the caller's drafts of both kinds, taking the loser's item with it.
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_draft_move_refuses_the_other_app_kind(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/raw_app/u/test-user/mvtaken_raw"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/mvtaken_app" }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"move onto a classic app draft was allowed: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"),
|
||||
"the refusal did not name the occupying kind: {body}"
|
||||
);
|
||||
|
||||
// Both drafts are untouched.
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let mut at = list
|
||||
.iter()
|
||||
.filter(|d| matches!(d["kind"].as_str(), Some("app") | Some("raw_app")))
|
||||
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
|
||||
.collect::<Vec<_>>();
|
||||
at.sort();
|
||||
assert_eq!(
|
||||
at,
|
||||
vec![
|
||||
("app", "u/test-user/mvtaken_app"),
|
||||
("raw_app", "u/test-user/mvtaken_raw")
|
||||
],
|
||||
"{list:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Teammates' drafts of one item share its path by design, so another user's row is no
|
||||
/// obstacle — except across the app pair, where the two kinds are different items on one
|
||||
/// deployed path: deploying either strands the other, and deleting the app takes both.
|
||||
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
|
||||
async fn test_draft_move_refuses_another_users_other_app_kind(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let move_to = |kind: &'static str, from: &'static str, to: &'static str| async move {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/{kind}/{from}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": to }))
|
||||
.send()
|
||||
.await?;
|
||||
Ok::<_, anyhow::Error>((resp.status(), resp.text().await?))
|
||||
};
|
||||
|
||||
let (status, body) = move_to(
|
||||
"app",
|
||||
"u/test-user/mvtaken_app",
|
||||
"u/test-user/mvtaken_theirs",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"a classic app was moved onto another user's raw app: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("holds another user's raw app draft"),
|
||||
"the refusal did not name the occupant: {body}"
|
||||
);
|
||||
|
||||
// And the other direction, where the occupant reads as the classic kind.
|
||||
let (status, body) = move_to(
|
||||
"raw_app",
|
||||
"u/test-user/mvtaken_raw",
|
||||
"u/test-user/mvtaken_app_theirs",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"a raw app was moved onto another user's classic app: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("holds another user's app draft"),
|
||||
"the refusal did not name the occupant: {body}"
|
||||
);
|
||||
|
||||
// The same-kind case is the ordinary one: two users' drafts of one raw app.
|
||||
let (status, body) = move_to(
|
||||
"raw_app",
|
||||
"u/test-user/mvtaken_raw",
|
||||
"u/test-user/mvtaken_theirs",
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a raw app was refused beside another user's raw-app draft: {body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -78,46 +78,3 @@ async fn test_draft_write_strips_nul(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The lineage the server derives for `draft.base` comes from the same value: read before
|
||||
/// the sanitizer, a NUL in it costs the draft its base, which then reads as up to date
|
||||
/// whatever the head is.
|
||||
#[sqlx::test(fixtures("drafts_nul"))]
|
||||
async fn test_draft_base_is_derived_from_sanitized_value(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/dnul-ws");
|
||||
|
||||
let resp = authed(client().post(format!(
|
||||
"{base}/drafts/update/script/u/dnul-admin/poisoned_base"
|
||||
)))
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"summary": "s",
|
||||
"path": "u/dnul-admin/poisoned_base",
|
||||
"content": "x",
|
||||
"parent_hash": "0000000000001b62\u{0}"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"save refused: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
let stored: Option<String> = sqlx::query_scalar(
|
||||
"SELECT base FROM draft WHERE workspace_id = 'dnul-ws' AND path = 'u/dnul-admin/poisoned_base'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
stored.as_deref(),
|
||||
Some("0000000000001b62"),
|
||||
"the NUL cost the draft its base: {stored:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
//! A draft save addressed to a path its item moved away from lands on the moved draft.
|
||||
//!
|
||||
//! A move carries every draft on the item to the new path and records where they
|
||||
//! went. An editor left open across it still saves to the old path; the server puts
|
||||
//! the save on the moved draft, keeps the path keys the move gave it, and names the
|
||||
//! new path so the editor can follow. Without the record the save would plant a
|
||||
//! phantom draft-only item at the old location.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Hex form of script hash 7030, the way the API takes a parent hash.
|
||||
const HEAD_HASH: &str = "0000000000001b76";
|
||||
|
||||
async fn own_draft_value(port: u16, path: &str) -> anyhow::Result<Value> {
|
||||
let draft: Value = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(draft["value"].clone())
|
||||
}
|
||||
|
||||
async fn own_draft_paths(port: u16) -> anyhow::Result<Vec<String>> {
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(list
|
||||
.iter()
|
||||
.filter(|d| d["kind"] == "script")
|
||||
.filter_map(|d| d["path"].as_str().map(String::from))
|
||||
.filter(|p| p.starts_with("u/test-user/follow_"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_save_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Rename from Home: a redeploy of the deployed content at the new path that
|
||||
// keeps the deployer's own draft, so the draft is carried rather than consumed.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/follow_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {},
|
||||
"skip_draft_deletion": true
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
|
||||
// A script draft's `path` is where deploying it lands, so it moves with the row.
|
||||
assert_eq!(
|
||||
own_draft_value(port, "u/test-user/follow_b").await?["path"],
|
||||
"u/test-user/follow_b"
|
||||
);
|
||||
|
||||
// The editor is still on the old path and writes that path back into the value.
|
||||
let saved: Value = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"path": "u/test-user/follow_a",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"content": "edited after the move"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["status"], "saved", "save refused: {saved}");
|
||||
assert_eq!(
|
||||
saved["path"], "u/test-user/follow_b",
|
||||
"save did not follow the row: {saved}"
|
||||
);
|
||||
|
||||
// The write landed on the carried row; nothing reappeared at the old path.
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
|
||||
let draft = own_draft_value(port, "u/test-user/follow_b").await?;
|
||||
assert_eq!(draft["content"], "edited after the move", "{draft}");
|
||||
assert_eq!(draft["path"], "u/test-user/follow_b", "{draft}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A draft-only move rewrites both path keys. The owner's open editor still carries
|
||||
/// the typed path it had, which names neither the old nor the new path; the moved
|
||||
/// draft's own keys have to win, or the save walks the item back.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_save_follows_a_draft_only_move(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/moved" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let saved: Value = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"path": "u/test-user/friendly",
|
||||
"draft_path": "u/test-user/friendly",
|
||||
"summary": "D",
|
||||
"content": "edited after the move"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["path"], "u/test-user/moved", "{saved}");
|
||||
|
||||
let draft = own_draft_value(port, "u/test-user/moved").await?;
|
||||
assert_eq!(draft["content"], "edited after the move", "{draft}");
|
||||
assert_eq!(draft["path"], "u/test-user/moved", "{draft}");
|
||||
assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename `from` to `to` the way Home does: redeploy the deployed content at the new
|
||||
/// path, keeping the deployer's own draft so it is carried rather than consumed.
|
||||
/// Returns the new head's hash.
|
||||
async fn rename(port: u16, from_hash: &str, to: &str) -> anyhow::Result<String> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": to,
|
||||
"parent_hash": from_hash,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {},
|
||||
"skip_draft_deletion": true
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let hash = resp.text().await?;
|
||||
assert_eq!(status, 201, "rename to {to} failed: {hash}");
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Save the draft as an editor still bound to `url_path` would. Returns the path the
|
||||
/// save landed at.
|
||||
async fn save_at(port: u16, url_path: &str, content: &str) -> anyhow::Result<String> {
|
||||
let saved: Value = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/{url_path}"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"value": { "path": url_path, "summary": "A", "content": content, "language": "deno" }
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["status"], "saved", "save refused: {saved}");
|
||||
Ok(saved["path"].as_str().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
/// A record is kept to one hop, and a move back to the path it left ends it: both are
|
||||
/// three statements whose order decides the answer.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_move_records_stay_one_hop(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
let _c = rename(port, &b, "u/test-user/follow_c").await?;
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after two moves").await?,
|
||||
"u/test-user/follow_c",
|
||||
"a save at the first path did not reach the last"
|
||||
);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_move_back_ends_the_record(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
let _a = rename(port, &b, "u/test-user/follow_a").await?;
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after moving back").await?,
|
||||
"u/test-user/follow_a",
|
||||
"a save was routed off the path the item moved back to"
|
||||
);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_a"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A rename carries a teammate's row too: both its path keys follow, and the version
|
||||
/// it forked from does not move. A restamp there would clear their out-of-date prompt
|
||||
/// and let them deploy over the mover's version believing they were current.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_teammates_draft_follows_with_its_base(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
// Read from the pool: the teammate's row is another user's, and this asserts on
|
||||
// `base`, which no endpoint exposes for someone else's draft.
|
||||
let row: (String, String, Option<String>) = sqlx::query_as(
|
||||
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', base
|
||||
FROM draft WHERE workspace_id = 'test-workspace' AND typ = 'script'
|
||||
AND email = 'test2@windmill.dev'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
|
||||
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
|
||||
assert_eq!(
|
||||
row.2.as_deref(),
|
||||
Some(HEAD_HASH),
|
||||
"the teammate's base was restamped by someone else's rename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An item move and then the owner's own move of what is left: the two records have
|
||||
/// different scopes, so the owner's move has to extend the chain in its own scope or
|
||||
/// a save addressed to the first path stops at the abandoned middle one.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_an_owner_move_extends_an_item_move(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
// Archiving the script at the new path leaves the carried draft as a draft-only
|
||||
// item, which its owner can move through `/drafts/move`.
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_b'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_b"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_c" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after both moves").await?,
|
||||
"u/test-user/follow_c",
|
||||
"a save at the first path stopped at the path the owner's move left"
|
||||
);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redeploying at a path an owner's move routed away from ends that route: the live item
|
||||
/// owns its path again, and its saves must not follow the draft that left.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// `move_draft` ignores archived rows, so an archived script's draft can be moved away.
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_b" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Unarchiving redeploys at the same path, with the archived version as parent.
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_a").await?;
|
||||
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "for the live script").await?,
|
||||
"u/test-user/follow_a",
|
||||
"a save for the redeployed script followed the draft that moved away"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A draft written before the NUL sanitizer still has to follow a move: its path keys are
|
||||
/// what a deploy of it would land on, so the carry rewrites them, sanitizing the value it
|
||||
/// could not otherwise parse.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_poisoned_draft_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// The teammate's row, rewritten the way a pre-sanitizer client left one: a real NUL
|
||||
// escape in the content, both path keys naming the path the item is about to leave.
|
||||
sqlx::query(
|
||||
r#"UPDATE draft SET value = '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a",
|
||||
"parent_hash": "0000000000001b76", "summary": "A", "content": "a\u0000b"}'
|
||||
WHERE email = 'test2@windmill.dev'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
let row: (String, String, String) = sqlx::query_as(
|
||||
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', value::jsonb ->> 'content'
|
||||
FROM draft WHERE email = 'test2@windmill.dev'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
|
||||
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
|
||||
assert_eq!(row.2, "ab", "the NUL survived the rewrite");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A discard whose answer is lost is retried, and the retry finds nothing to delete. It
|
||||
/// still has to name where the item went: the editor asking is on the path it left, and a
|
||||
/// reload there lands on nothing.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_retried_discard_still_names_the_destination(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
let discard = |token: &'static str| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&json!({ "value": null }))
|
||||
.send()
|
||||
.await?
|
||||
.json::<Value>()
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let first = discard("SECRET_TOKEN").await?;
|
||||
assert_eq!(first["status"], "saved", "{first}");
|
||||
assert_eq!(first["path"], "u/test-user/follow_b", "{first}");
|
||||
|
||||
let retry = discard("SECRET_TOKEN").await?;
|
||||
assert_eq!(
|
||||
retry["status"], "saved",
|
||||
"the retry was not idempotent: {retry}"
|
||||
);
|
||||
assert_eq!(retry["path"], "u/test-user/follow_b", "{retry}");
|
||||
|
||||
// A third user has no draft on this item, so their discard deletes nothing and the
|
||||
// destination is a move record and nothing else: it is answered only to a caller who
|
||||
// can read that path.
|
||||
let other = discard("SECRET_TOKEN_3").await?;
|
||||
assert_eq!(other["status"], "saved", "{other}");
|
||||
assert_eq!(
|
||||
other["path"],
|
||||
Value::Null,
|
||||
"a user who cannot read the destination was told where the item went: {other}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The legacy workspace-level row is carried by a rename like any other draft, and the
|
||||
/// record that routes saves to it covers every caller — so discarding it from a page that
|
||||
/// still names the old path has to reach it where it went.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_legacy_discard_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
|
||||
'{"path": "u/test-user/follow_a", "summary": "legacy", "content": "x"}', NULL)"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "value": null, "legacy": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"discard failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let left: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND email IS NULL",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
left, 0,
|
||||
"the legacy draft survived a discard aimed at its old path"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deploying a draft that a move carried off an archived script: its parent is still the
|
||||
/// version at the old path, so the deploy renames from there and carries what is left over
|
||||
/// — onto the very draft being deployed. That row is this deploy's own, not an item in its
|
||||
/// way, or the deploy is refused and every retry refuses again.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_moved_draft_deploys_at_its_new_path(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_b" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Deploy it where it now lives, still parented on the archived version it forked from.
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/follow_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 2 }",
|
||||
"language": "deno",
|
||||
"schema": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 201, "the moved draft could not be deployed: {body}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A route is only as good as the item it points at: when an unrelated item claims the
|
||||
/// destination, a save still addressed to the old path must stay where it is rather than
|
||||
/// land on that item's draft.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
|
||||
async fn test_a_reused_destination_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/follow_b" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"move failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
// Someone else's item takes the destination, and the moved draft goes with the
|
||||
// deploy that consumes it.
|
||||
sqlx::query("DELETE FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/follow_b'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/follow_b",
|
||||
"summary": "unrelated",
|
||||
"description": "",
|
||||
"content": "export function main() { return 3 }",
|
||||
"language": "deno",
|
||||
"schema": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "create failed: {}", resp.text().await?);
|
||||
|
||||
assert_eq!(
|
||||
save_at(port, "u/test-user/follow_a", "after the destination was reused").await?,
|
||||
"u/test-user/follow_a",
|
||||
"a save was routed onto the item that now owns the destination"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
-- Fixture for the two-path-key move test.
|
||||
--
|
||||
-- Two draft-only scripts owned by test-user, both parked at a generated storage
|
||||
-- key the way a session-created draft is. One carries the `draft_path` mirror the
|
||||
-- session editor writes while the typed path differs from that key; the other
|
||||
-- carries no mirror at all, which is what a plain draft looks like.
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
|
||||
('test-workspace', 'u/test-user/draft_mirror', 'script',
|
||||
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly",
|
||||
"content": "x", "language": "bun", "summary": "S"}',
|
||||
'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/draft_plain', 'script',
|
||||
'{"path": "u/test-user/draft_plain",
|
||||
"content": "x", "language": "bun", "summary": "S"}',
|
||||
'test@windmill.dev');
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
-- Fixture for refusing a rename onto a path a draft already occupies.
|
||||
--
|
||||
-- A deployed script at `u/test-user/mvtaken_a` (hash 7010 = 0x1b62), and a
|
||||
-- never-deployed draft of test-user's own at `u/test-user/mvtaken_b`, the path
|
||||
-- the rename will target. Nothing deployed lives at the target, so only the
|
||||
-- draft can refuse the move.
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms)
|
||||
VALUES ('test-workspace', 7010, 'u/test-user/mvtaken_a',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script',
|
||||
'{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}',
|
||||
'test@windmill.dev');
|
||||
|
||||
-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it
|
||||
-- together with the caller's, so it occupies the path just as an own draft does.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script',
|
||||
'{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}',
|
||||
NULL);
|
||||
|
||||
-- A draft-only classic app and a draft-only raw app of the same owner. They share
|
||||
-- the `app` table, so one occupies the other's path. The third is another user's raw
|
||||
-- app: a different item at its own path, not a second copy of anyone's.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
|
||||
('test-workspace', 'u/test-user/mvtaken_app', 'app',
|
||||
'{"summary": "classic", "value": {}}', 'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app',
|
||||
'{"summary": "raw", "files": {}}', 'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app',
|
||||
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app',
|
||||
'{"summary": "theirs", "value": {}}', 'test2@windmill.dev');
|
||||
@@ -1,29 +0,0 @@
|
||||
-- Fixture for saving a draft to the path its item moved away from.
|
||||
--
|
||||
-- A deployed script at `u/test-user/follow_a` (hash 7030 = 0x1b76) with the
|
||||
-- deployer's own draft on it. The test renames the script and then saves the
|
||||
-- draft from an editor still bound to the old path.
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms)
|
||||
VALUES ('test-workspace', 7030, 'u/test-user/follow_a',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
|
||||
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email, base)
|
||||
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
|
||||
'{"path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}',
|
||||
'test@windmill.dev', '0000000000001b76');
|
||||
|
||||
-- A draft-only script parked at a generated storage key, its typed path elsewhere.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email)
|
||||
VALUES ('test-workspace', 'u/test-user/draft_store', 'script',
|
||||
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}',
|
||||
'test@windmill.dev');
|
||||
|
||||
-- A teammate's draft on the same deployed script, forked from the same head. The
|
||||
-- rename must carry it too, without touching the version it forked from.
|
||||
INSERT INTO draft (workspace_id, path, typ, value, email, base)
|
||||
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
|
||||
'{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "teammate draft"}',
|
||||
'test2@windmill.dev', '0000000000001b76');
|
||||
@@ -3312,60 +3312,3 @@ export function main(i: number) {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A `$flow_expr[...]` step tag is resolved from the flow's state before the step is pushed, and
|
||||
// one that cannot be resolved fails the step instead of queueing it on a tag no worker serves.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_flow_expr_step_tag(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
let step = |id: &str, tag: Option<&str>| {
|
||||
flow_module(
|
||||
id,
|
||||
FlowModuleValue::RawScript {
|
||||
input_transforms: Default::default(),
|
||||
language: ScriptLang::Deno,
|
||||
content: "export function main() { return { lang: 'bun' } }".to_string(),
|
||||
path: None,
|
||||
lock: None,
|
||||
tag: tag.map(str::to_string),
|
||||
concurrency_settings: Default::default(),
|
||||
is_trigger: None,
|
||||
assets: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
let flow = FlowValue {
|
||||
modules: vec![
|
||||
step("a", None),
|
||||
step("b", Some("$flow_expr[results.a.lang]")),
|
||||
step("c", Some("nobody-serves-$flow_expr[a.lang]")),
|
||||
],
|
||||
same_worker: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
let b_tag = sqlx::query_scalar::<_, String>(
|
||||
"SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(b_tag, "bun");
|
||||
|
||||
assert!(!job.success);
|
||||
let result = job.json_result().unwrap();
|
||||
let message = result["error"]["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("Could not resolve the step tag `nobody-serves-$flow_expr[a.lang]`"),
|
||||
"got {result:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1530,31 +1530,6 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool<Postgres>) {
|
||||
clear_settings_and_configs(&db).await;
|
||||
let before = count_global_settings(&db).await;
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"max_token_expiration_days".to_string(),
|
||||
serde_json::json!("7.0"),
|
||||
);
|
||||
|
||||
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
||||
&db,
|
||||
&BTreeMap::new(),
|
||||
&desired,
|
||||
)
|
||||
.await
|
||||
.expect_err("a ceiling the token routes cannot read must fail the sync");
|
||||
assert!(
|
||||
err.to_string().contains("max_token_expiration_days"),
|
||||
"the error should name the offending setting, got: {err}"
|
||||
);
|
||||
assert_eq!(count_global_settings(&db).await, before);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<Postgres>) {
|
||||
// The declarative writers (the sync-config CLI, the operator's ConfigMap
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted
|
||||
* for, and job logs are persisted to `job_logs` and optionally to object storage,
|
||||
* so a script that echoes the token would otherwise park a live credential in
|
||||
* durable storage. `run_worker` registers the token with `sensitive_log_masks`
|
||||
* for the job it pulled; this pins that the persisted log carries the masked form.
|
||||
*/
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::{
|
||||
jobs::{JobPayload, RawCode},
|
||||
scripts::ScriptLang,
|
||||
};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header.
|
||||
/// The masked form keeps only `jwt` + the last three characters, so it never matches.
|
||||
const RAW_TOKEN_PREFIX: &str = "jwt_ey";
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_job_token_masked_in_persisted_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "echo \"running with --token $WM_TOKEN\"".to_string(),
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Bash,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
tag: None,
|
||||
}))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
assert!(job.success, "job should have succeeded");
|
||||
|
||||
let logs =
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(
|
||||
!logs.contains(RAW_TOKEN_PREFIX),
|
||||
"an unmasked job token reached the persisted logs: {logs}"
|
||||
);
|
||||
assert!(
|
||||
logs.contains("secret value was masked"),
|
||||
"expected the masking notice in logs: {logs}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// nativets runs V8 in-process and persists `console.log` output through its own
|
||||
/// channel, so it is masked by a different mechanism than the bash case above and
|
||||
/// needs its own guard.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_job_token_masked_in_nativets_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(),
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Nativets,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
tag: None,
|
||||
}))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
assert!(job.success, "job should have succeeded");
|
||||
|
||||
let logs =
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(
|
||||
!logs.contains(RAW_TOKEN_PREFIX),
|
||||
"an unmasked job token reached the persisted logs: {logs}"
|
||||
);
|
||||
assert!(
|
||||
logs.contains("secret value was masked"),
|
||||
"expected the masking notice in logs: {logs}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1249,10 +1249,6 @@ pub async fn register_token_expiry_notification(
|
||||
if !windmill_common::auth::is_user_token(label) {
|
||||
return;
|
||||
}
|
||||
let warning_days = windmill_common::auth::TOKEN_EXPIRY_WARNING_DAYS;
|
||||
if expiration <= chrono::Utc::now() + chrono::Duration::days(warning_days.into()) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
token_hash,
|
||||
|
||||
@@ -51,10 +51,7 @@ use windmill_common::{
|
||||
jobs::JobPayload,
|
||||
schedule::Schedule,
|
||||
triggers::MovedNativeTrigger,
|
||||
utils::{
|
||||
http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination,
|
||||
RunnableKind, StripPath,
|
||||
},
|
||||
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
|
||||
};
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
@@ -750,14 +747,6 @@ async fn create_flow(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
windmill_common::user_drafts::clear_draft_moves_from(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Flow],
|
||||
&nf.path,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -926,36 +915,25 @@ pub struct FlowVersion {
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_msg: Option<String>,
|
||||
/// Who deployed this version — the diff's version picker names them so a reader
|
||||
/// can tell their own deploys from a teammate's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_flow_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<FlowVersion>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:read:{}", path))?;
|
||||
// Unasked-for, this listing stays whole: the history panels, the restart picker and
|
||||
// the CLI all read it without paging. The diff picker asks for a page.
|
||||
let (per_page, offset) = paginate_optional(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let flows = sqlx::query_as!(
|
||||
FlowVersion,
|
||||
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
|
||||
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
|
||||
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
|
||||
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
|
||||
ORDER BY flow_version.created_at DESC
|
||||
LIMIT $3 OFFSET $4",
|
||||
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
|
||||
ORDER BY flow_version.created_at DESC",
|
||||
path,
|
||||
w_id,
|
||||
per_page,
|
||||
offset,
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
@@ -975,7 +953,7 @@ async fn get_latest_version(
|
||||
|
||||
let version = sqlx::query_as!(
|
||||
FlowVersion,
|
||||
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
|
||||
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
|
||||
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
|
||||
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
|
||||
ORDER BY flow_version.created_at DESC",
|
||||
@@ -1460,20 +1438,6 @@ async fn update_flow(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if is_new_path {
|
||||
// Everything left at the old path is a draft this deploy didn't consume
|
||||
// — teammates' rows, and the deployer's own when the caller asked us to
|
||||
// keep it. Carry them rather than strand them.
|
||||
windmill_common::user_drafts::move_drafts_for_path(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Flow],
|
||||
flow_path,
|
||||
&nf.path,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Who may read and change a data table's grants and owners. On the Enterprise Edition: its
|
||||
//! administrators, from the workspace that governs it. Without it: nobody. Each refusal is decided
|
||||
//! before anything connects to the data table, so the fixture's database never has to exist.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn grant_select_on_public() -> Value {
|
||||
json!({
|
||||
"target": {"kind": "schema", "schema": "public"},
|
||||
"change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"],
|
||||
"scope": "all_tables"},
|
||||
"statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#]
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_acl(
|
||||
port: u16,
|
||||
w_id: &str,
|
||||
action: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<reqwest::Response> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&grant_select_on_public())
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A fork reaches the data table through a pointer: it may use it, never change what each role may
|
||||
/// touch on it — not even as an admin of the fork.
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_fork_cannot_change_access_on_the_data_table_it_points_at(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
for action in ["plan", "apply"] {
|
||||
let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn a_member_who_is_not_an_admin_cannot_change_access(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
for action in ["plan", "apply"] {
|
||||
let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Not even reading, and not even on a data table that is not under roles — which any member
|
||||
/// reaches, so only the edition stands between them and the instance's credentials.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn only_the_enterprise_edition_has_the_access_editor(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = datatable #- '{datatables,main,permissions}'
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let read = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/workspaces/datatable_acl/main?kind=database"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
let mut responses = vec![("read", read)];
|
||||
for action in ["plan", "apply"] {
|
||||
responses.push((
|
||||
action,
|
||||
post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?,
|
||||
));
|
||||
}
|
||||
for (action, resp) in responses {
|
||||
assert_eq!(resp.status(), 400, "{action}");
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("Data table roles are a Windmill Enterprise Edition feature"),
|
||||
"{action}: {body}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -912,6 +912,18 @@ async fn a_stored_name_containing_a_question_mark_resolves_as_itself(
|
||||
resolve("main?dt").await.is_err(),
|
||||
"an unknown parameter was ignored"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, '{datatables,main?role=analytics}', datatable->'datatables'->'main')
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
resolve("main?role=analytics").await.is_err(),
|
||||
"a reference naming both a stored data table and a role on another resolved to one of them"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1009,6 +1021,70 @@ async fn an_entry_without_roles_cannot_newly_reach_a_database_under_roles(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Browsing names the role it connects as, and a role the caller may not use is refused rather
|
||||
/// than quietly listed as the default. The refusal is decided before connecting, so the fixture's
|
||||
/// database never has to exist.
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn browsing_as_a_role_the_caller_may_not_use_is_refused(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
// `test-user-2` is a tenant of `analytics` only.
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"{base}/list_datatable_tables?role_for=main&role=admin"
|
||||
)),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: Value = resp.json().await?;
|
||||
let entry = body
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().find(|e| e["datatable_name"] == "main"))
|
||||
.expect("main is listed");
|
||||
assert_eq!(entry["usable_roles"], json!(["analytics"]), "{entry}");
|
||||
assert_eq!(entry["default_role"], "analytics", "{entry}");
|
||||
assert_eq!(entry["permissioned"], true, "{entry}");
|
||||
assert_eq!(entry["instance"], true, "{entry}");
|
||||
let error = entry["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
error.contains("Not allowed to use role 'admin'"),
|
||||
"listed as another role than the one asked for: {entry}"
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"{base}/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=admin"
|
||||
)),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
assert!(
|
||||
text.contains("Not allowed to use role 'admin'"),
|
||||
"{status}: {text}"
|
||||
);
|
||||
|
||||
// A role means nothing without the data table it belongs to.
|
||||
let resp = authed(
|
||||
client().get(format!("{base}/list_datatable_tables?role=analytics")),
|
||||
"SECRET_TOKEN_2",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
|
||||
async fn an_alias_saved_elsewhere_waits_for_roles_going_on_for_its_database(
|
||||
db: Pool<Postgres>,
|
||||
|
||||
@@ -311,130 +311,3 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool<Postgres>) -> any
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apps, schedules, triggers and their drafts cannot drop an identity the way scripts and flows
|
||||
/// do, so one naming nobody in the fork goes to its creator while one that still resolves stays.
|
||||
/// Forked as an admin, whose app policies the clone otherwise keeps.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base_url = format!("http://localhost:{}/api", server.addr.port());
|
||||
|
||||
let stranger = json!({
|
||||
"on_behalf_of": "u/test-user-2",
|
||||
"on_behalf_of_email": "test2@windmill.dev",
|
||||
"execution_mode": "publisher",
|
||||
});
|
||||
sqlx::query(
|
||||
"INSERT INTO app (workspace_id, path, summary, policy, versions)
|
||||
VALUES ('test-workspace', 'u/test-user/stranger', '', $1, '{}'),
|
||||
('test-workspace', 'u/test-user/group', '', $2, '{}')",
|
||||
)
|
||||
.bind(&stranger)
|
||||
.bind(json!({
|
||||
"on_behalf_of": "g/all",
|
||||
"on_behalf_of_email": "group-all@windmill.dev",
|
||||
"execution_mode": "publisher",
|
||||
}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// The clone re-aggregates `versions` from `app_version`, and the column is NOT NULL.
|
||||
sqlx::query(
|
||||
"WITH v AS (
|
||||
INSERT INTO app_version (app_id, value, created_by)
|
||||
SELECT id, '{}'::json, 'test-user' FROM app WHERE workspace_id = 'test-workspace'
|
||||
RETURNING id, app_id
|
||||
)
|
||||
UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = v.app_id",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
|
||||
VALUES ('test-workspace', 'u/test-user/stranger', 'raw_app', $1::json, NOW(), 'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/stranger', 'trigger_websocket', $2::json, NOW(), 'test@windmill.dev'),
|
||||
('test-workspace', 'u/test-user/nul', 'raw_app', $3::json, NOW(), 'test@windmill.dev')",
|
||||
)
|
||||
.bind(json!({ "policy": stranger }))
|
||||
.bind(json!({ "permissioned_as": "u/test-user-2" }))
|
||||
// Saved before drafts were stripped of NULs: any jsonb parse of it raises, so it must be
|
||||
// skipped rather than abort the fork. Built from parts because a NUL escape can't sit in source.
|
||||
.bind(format!(
|
||||
r#"{{"policy":{{"on_behalf_of":"u/test-user-2"}},"files":{{"f":"a{}u0000"}}}}"#,
|
||||
"\\"
|
||||
))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, email, permissioned_as, enabled)
|
||||
VALUES ('test-workspace', 'u/test-user/stranger', 'test-user', '0 0 * * * *', 'u/test-user/s', 'test2@windmill.dev', 'u/test-user-2', false)",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, edited_by, permissioned_as, mode)
|
||||
VALUES ('test-workspace', 'u/test-user/stranger', 'ws://localhost', 'u/test-user/s', false, 'test-user', 'u/test-user-2', 'disabled')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{base_url}/w/test-workspace/workspaces/create_fork"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "id": "wm-fork-repoint", "name": "Fork", "color": "#0000ff" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"creating the fork: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let text = |sql: &'static str| sqlx::query_scalar::<_, String>(sql).fetch_one(&db);
|
||||
assert_eq!(
|
||||
text("SELECT (policy->>'on_behalf_of') || ' ' || (policy->>'on_behalf_of_email') FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger'").await?,
|
||||
"u/test-user test@windmill.dev"
|
||||
);
|
||||
assert_eq!(
|
||||
text("SELECT policy->>'on_behalf_of' FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/group'").await?,
|
||||
"g/all"
|
||||
);
|
||||
assert_eq!(
|
||||
text("SELECT value->'policy'->>'on_behalf_of' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger' AND typ = 'raw_app'").await?,
|
||||
"u/test-user"
|
||||
);
|
||||
// `clone_drafts` strips a NUL escape as it copies, so the row reaches the fork
|
||||
// parseable and the repoint below reaches it like any other draft's. The rule this
|
||||
// guards is that the fork completes and no identity naming nobody survives it; the
|
||||
// skip only ever existed because `to_jsonb` raises on a value still holding one.
|
||||
assert_eq!(
|
||||
text("SELECT CASE WHEN strpos(value::text, 'u/test-user-2') > 0 THEN 'kept' ELSE 'rewritten' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
|
||||
"rewritten"
|
||||
);
|
||||
// And it arrives without the poison that made it a special case.
|
||||
assert_eq!(
|
||||
text("SELECT CASE WHEN position(chr(92) || 'u0000' in value::text) > 0 THEN 'poisoned' ELSE 'clean' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
|
||||
"clean"
|
||||
);
|
||||
assert_eq!(
|
||||
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
|
||||
"u/test-user"
|
||||
);
|
||||
assert_eq!(
|
||||
text("SELECT permissioned_as || ' ' || email FROM schedule WHERE workspace_id = 'wm-fork-repoint'").await?,
|
||||
"u/test-user test@windmill.dev"
|
||||
);
|
||||
assert_eq!(
|
||||
text(
|
||||
"SELECT permissioned_as FROM websocket_trigger WHERE workspace_id = 'wm-fork-repoint'"
|
||||
)
|
||||
.await?,
|
||||
"u/test-user"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and
|
||||
/// developers at their parent role, even when a developer forks it; operators are left out. The
|
||||
/// copies are manual members: a parent membership that came from an instance group must not carry
|
||||
/// that provenance into a fork that does not configure the group.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_fork_adds_parent_admins_and_developers(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base_url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/workspaces",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, added_via)
|
||||
VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false,
|
||||
'{\"source\": \"instance_group\", \"group\": \"devs\"}')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{base_url}/edit_add_admins_and_developers_to_forks"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "add_admins_and_developers_to_forks": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"enabling the setting: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(format!("{base_url}/create_fork"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
||||
.json(&json!({ "id": "wm-fork-team", "name": "Team fork" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"creating the fork: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let members: Vec<(String, bool, bool)> = sqlx::query_as(
|
||||
"SELECT username, is_admin, added_via IS NULL FROM usr
|
||||
WHERE workspace_id = 'wm-fork-team' ORDER BY username",
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
members,
|
||||
vec![
|
||||
("test-user".to_string(), true, true),
|
||||
("test-user-2".to_string(), false, true),
|
||||
("test-user-4".to_string(), false, true),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -115,60 +115,6 @@ async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn confirmed_login_link_is_spent_by_the_click_not_the_page(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api");
|
||||
|
||||
let resp = client()
|
||||
.post(format!("{base}/users/login_links"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({"email": "test2@windmill.dev", "confirm": true}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let link = resp.json::<serde_json::Value>().await?;
|
||||
// The URL handed out is the frontend page, not the API path that signs in on a GET.
|
||||
let token = link["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.split_once("/user/login_link?token=")
|
||||
.expect("confirmation page url")
|
||||
.1
|
||||
.to_string();
|
||||
|
||||
let confirm = || {
|
||||
client()
|
||||
.post(format!("{base}/auth/login_link/{token}"))
|
||||
.send()
|
||||
};
|
||||
let resp = confirm().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.any(|c| c.to_str().unwrap().starts_with("token=")));
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["location"],
|
||||
"/user/workspaces"
|
||||
);
|
||||
|
||||
let resp = confirm().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.headers().get("set-cookie").is_none());
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["location"],
|
||||
"/user/login_link_expired?reason=used"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
//! `max_token_expiration_days`: the instance-wide ceiling on how far ahead a token a caller
|
||||
//! picks the lifetime of may expire, and the service-account exemption.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::types::chrono::{DateTime, Utc};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const DAY: u64 = 24 * 60 * 60;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn from_now(secs: u64) -> DateTime<Utc> {
|
||||
Utc::now() + std::time::Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
async fn set_max(db: &Pool<Postgres>, value: serde_json::Value) {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ('max_token_expiration_days', $1)
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind(value)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Mints as `test2@windmill.dev`, a plain member of `test-workspace`.
|
||||
async fn create_token(port: u16, body: serde_json::Value) -> reqwest::Response {
|
||||
client()
|
||||
.post(format!("http://localhost:{port}/api/users/tokens/create"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn stored_expiration(db: &Pool<Postgres>, label: &str) -> Option<DateTime<Utc>> {
|
||||
sqlx::query_scalar::<_, Option<DateTime<Utc>>>("SELECT expiration FROM token WHERE label = $1")
|
||||
.bind(label)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_max_token_expiration_days_shortens_user_tokens(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = create_token(port, json!({ "label": "unset" })).await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
assert_eq!(
|
||||
stored_expiration(&db, "unset").await,
|
||||
None,
|
||||
"with no setting a token may still have no expiration"
|
||||
);
|
||||
|
||||
// Refused at write time: the token routes can only read a value they cannot parse as no
|
||||
// ceiling at all.
|
||||
let resp = client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/settings/global/max_token_expiration_days"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "value": 7.5 }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
set_max(&db, json!(7)).await;
|
||||
|
||||
// The token form reads the ceiling as whoever is creating the token, usually not a
|
||||
// superadmin, so it can offer only expirations the server would keep.
|
||||
let resp = client()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/settings/global/max_token_expiration_days"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(resp.text().await?, "7");
|
||||
|
||||
let resp = create_token(port, json!({ "label": "none asked" })).await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let expiration = stored_expiration(&db, "none asked")
|
||||
.await
|
||||
.expect("a token asking for no expiration gets the ceiling");
|
||||
assert!(
|
||||
expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY),
|
||||
"expected the 7 day ceiling, got {expiration}"
|
||||
);
|
||||
|
||||
let resp = create_token(
|
||||
port,
|
||||
json!({ "label": "past the ceiling", "expiration": from_now(30 * DAY) }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let expiration = stored_expiration(&db, "past the ceiling").await.unwrap();
|
||||
assert!(
|
||||
expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY),
|
||||
"expected an expiration past the ceiling to be shortened to it, got {expiration}"
|
||||
);
|
||||
|
||||
let resp = create_token(
|
||||
port,
|
||||
json!({ "label": "within", "expiration": from_now(3 * DAY) }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let expiration = stored_expiration(&db, "within").await.unwrap();
|
||||
assert!(
|
||||
expiration <= from_now(3 * DAY),
|
||||
"an expiration within the ceiling must be kept, got {expiration}"
|
||||
);
|
||||
|
||||
// The settings UI stores an integer, but the YAML instance config and config sync can write
|
||||
// the same whole number as a string or as `5.0`. Reading either as "unset" would silently
|
||||
// drop the ceiling, while the token form (`parseMaxTokenExpirationDays`) still showed it.
|
||||
for (stored, label) in [
|
||||
(json!("5"), "string setting"),
|
||||
(json!(5.0), "float setting"),
|
||||
] {
|
||||
set_max(&db, stored).await;
|
||||
let resp = create_token(port, json!({ "label": label })).await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let expiration = stored_expiration(&db, label).await;
|
||||
assert!(
|
||||
expiration.is_some_and(|e| e > from_now(4 * DAY) && e <= from_now(5 * DAY)),
|
||||
"{label}: expected the 5 day ceiling, got {expiration:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A superadmin impersonating a user picks the lifetime too, so the ceiling applies there;
|
||||
// left out, it would be the one way to mint a token that never expires.
|
||||
let resp = client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/users/tokens/impersonate"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "label": "impersonated", "impersonate_email": "test3@windmill.dev" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
assert!(
|
||||
stored_expiration(&db, "impersonated").await.is_some(),
|
||||
"an impersonation token asking for no expiration gets the ceiling"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_service_accounts_are_exempt(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
set_max(&db, json!(7)).await;
|
||||
// The same email is a service account in one workspace and an ordinary user in another.
|
||||
sqlx::query(
|
||||
"UPDATE usr SET is_service_account = true
|
||||
WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
||||
VALUES ('other', 'test2@windmill.dev', 'test-user-2', false, 'User')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// The token form decides the exemption from this list, so it must carry each membership's flag.
|
||||
let memberships: serde_json::Value = client()
|
||||
.get(format!("http://localhost:{port}/api/workspaces/users"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
for (workspace, is_service_account) in [("test-workspace", true), ("other", false)] {
|
||||
let membership = memberships["workspaces"]
|
||||
.as_array()
|
||||
.and_then(|ws| ws.iter().find(|w| w["id"] == workspace))
|
||||
.unwrap_or_else(|| panic!("{workspace} missing from {memberships}"));
|
||||
assert_eq!(membership["is_service_account"], json!(is_service_account));
|
||||
}
|
||||
|
||||
for (label, workspace_id, exempt) in [
|
||||
("own workspace", Some("test-workspace"), true),
|
||||
("other workspace", Some("other"), false),
|
||||
// A workspace-less token has no workspace to match, so a service account anywhere counts.
|
||||
("global", None, true),
|
||||
] {
|
||||
let resp = create_token(
|
||||
port,
|
||||
json!({ "label": label, "workspace_id": workspace_id }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 201);
|
||||
assert_eq!(
|
||||
stored_expiration(&db, label).await.is_none(),
|
||||
exempt,
|
||||
"{label}: expected exempt = {exempt}"
|
||||
);
|
||||
}
|
||||
|
||||
// Impersonation checks the impersonated account, not the superadmin minting the token.
|
||||
let resp = client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/users/tokens/impersonate"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "label": "impersonated service account", "impersonate_email": "test2@windmill.dev" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
assert_eq!(
|
||||
stored_expiration(&db, "impersonated service account").await,
|
||||
None
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path.
|
||||
//!
|
||||
//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and
|
||||
//! everything that reads a token, on both the workspaced and the gateway mount. Each half of
|
||||
//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer
|
||||
//! being moved outward (a client would lose the pointer that starts OAuth discovery), and
|
||||
//! refusing a token that was never valid catches it being moved inward past authentication
|
||||
//! (the URL-borne token would be hashed and looked up before anything refused it).
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the
|
||||
/// workspaced mount takes its workspace from the path, so one token reaches both.
|
||||
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
||||
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A token that is not in `token` at all. Authentication would refuse it on its own, so a
|
||||
/// refusal carrying the middleware's own wording is evidence nothing looked it up first.
|
||||
const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN";
|
||||
|
||||
async fn tools_list(url: &str) -> anyhow::Result<reqwest::Response> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(url)
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_token_query_param_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let workspaced =
|
||||
format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN");
|
||||
let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN");
|
||||
|
||||
assert_eq!(
|
||||
tools_list(&workspaced).await?.status(),
|
||||
200,
|
||||
"a URL-borne token is the documented default and must keep working while the switch is off"
|
||||
);
|
||||
assert_eq!(tools_list(&gateway).await?.status(), 200);
|
||||
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed);
|
||||
|
||||
for url in [&workspaced, &gateway] {
|
||||
let resp = tools_list(url).await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"{url} still admitted a token in the URL"
|
||||
);
|
||||
// What sends the client into the OAuth flow rather than leaving it stuck on a 401.
|
||||
assert!(
|
||||
resp.headers().contains_key("www-authenticate"),
|
||||
"{url} rejected without pointing at the authorization server"
|
||||
);
|
||||
}
|
||||
|
||||
// Refused before authentication, not after: an invalid token gets the middleware's own
|
||||
// message rather than the generic 401 that looking it up would produce.
|
||||
let resp = tools_list(&format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}"
|
||||
))
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
assert!(
|
||||
resp.text().await?.contains("does not accept a token in the MCP URL"),
|
||||
"an invalid URL token was answered by authentication, so the token was read before \
|
||||
the switch refused it"
|
||||
);
|
||||
|
||||
// The header stays open: it is the channel the OAuth flow itself hands tokens over on.
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("http://localhost:{port}/api/mcp/gateway"))
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.header("Authorization", "Bearer MCP_TOKEN")
|
||||
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! Which user tokens get an "expiring soon" warning queued when they are created.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::types::chrono::Utc;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const DAY: u64 = 24 * 60 * 60;
|
||||
|
||||
async fn warning_queued(db: &Pool<Postgres>, label: &str) -> bool {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM token_expiry_notification n
|
||||
JOIN token t ON t.token_hash = n.token_hash WHERE t.label = $1)",
|
||||
)
|
||||
.bind(label)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_short_lived_tokens_get_no_expiry_warning(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
for (label, lifetime) in [("short", DAY), ("long", 30 * DAY)] {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("http://localhost:{port}/api/users/tokens/create"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
||||
.json(&json!({
|
||||
"label": label,
|
||||
"expiration": Utc::now() + std::time::Duration::from_secs(lifetime),
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
}
|
||||
|
||||
assert!(
|
||||
!warning_queued(&db, "short").await,
|
||||
"a token whose whole lifetime fits in the warning window must not be warned about"
|
||||
);
|
||||
assert!(
|
||||
warning_queued(&db, "long").await,
|
||||
"a longer-lived token still gets its warning"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -275,67 +275,6 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stored repository tokens and webhook secrets are encrypted under the
|
||||
/// workspace key but never synced, so a rotation has to carry them over even
|
||||
/// when the caller skips re-encrypting variables.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_encryption_key_rotation_reencrypts_git_sync_secrets(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt};
|
||||
initialize_tracing().await;
|
||||
|
||||
create_folder(&db, "28103").await?;
|
||||
create_git_repo_resource(&db).await?;
|
||||
let sync_script_path = "f/28103/test_sync_script_git_secrets";
|
||||
create_sync_script(&db, sync_script_path).await?;
|
||||
setup_git_sync_config(&db, sync_script_path).await?;
|
||||
|
||||
let mc = build_crypt(&db, "test-workspace").await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE workspace_settings SET
|
||||
git_credentials = jsonb_build_array(jsonb_build_object(
|
||||
'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')),
|
||||
git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object(
|
||||
'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text))
|
||||
WHERE workspace_id = 'test-workspace'
|
||||
"#,
|
||||
)
|
||||
.bind(encrypt(&mc, "stored-token"))
|
||||
.bind(encrypt(&mc, "hook-secret"))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
let new_key = "c".repeat(64);
|
||||
let resp = authed(client().post(format!("{base}/encryption_key")))
|
||||
.json(&json!({"new_key": new_key, "skip_reencrypt": true}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"set_encryption_key failed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let (token, secret): (String, String) = sqlx::query_as(
|
||||
"SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}'
|
||||
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let new_mc = crypt_from_key_with_suffix(&new_key, "");
|
||||
assert_eq!(decrypt(&new_mc, token)?, "stored-token");
|
||||
assert_eq!(decrypt(&new_mc, secret)?, "hook-secret");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test for the non-debouncing fallback: a workspace whose sync
|
||||
/// script predates hub version 28103 must still receive git-sync jobs for the
|
||||
/// encryption_key entry and every re-encrypted secret. Before the fallback was
|
||||
|
||||
@@ -1166,47 +1166,3 @@ async fn test_create_service_account_drops_orphaned_group_memberships(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_edit_auto_invite_preserves_instance_groups(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
sqlx::query(
|
||||
r#"UPDATE workspace_settings
|
||||
SET auto_invite = '{"instance_groups": ["eng"], "instance_groups_roles": {"eng": "developer"}}'
|
||||
WHERE workspace_id = 'test-workspace'"#,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
// enable, then disable
|
||||
for body in [
|
||||
json!({"operator": false, "invite_all": true, "auto_add": false}),
|
||||
json!({}),
|
||||
] {
|
||||
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{body}: {}", resp.text().await?);
|
||||
|
||||
let auto_invite: serde_json::Value = sqlx::query_scalar(
|
||||
"SELECT auto_invite FROM workspace_settings WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(auto_invite["instance_groups"], json!(["eng"]), "{body}");
|
||||
assert_eq!(
|
||||
auto_invite["instance_groups_roles"],
|
||||
json!({"eng": "developer"}),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ use windmill_common::{
|
||||
min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait,
|
||||
},
|
||||
scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline},
|
||||
utils::{paginate_optional, paginate_without_limits, WarnAfterExt},
|
||||
utils::{paginate_without_limits, WarnAfterExt},
|
||||
worker::CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_object_store::upload_artifact_to_store;
|
||||
@@ -2312,20 +2312,6 @@ async fn create_script_internal<'c>(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if p_path != &ns.path {
|
||||
// Everything left at the old path is a draft this deploy didn't
|
||||
// consume — teammates' rows, and the deployer's own when the caller
|
||||
// asked us to keep it. Carry them rather than strand them.
|
||||
windmill_common::user_drafts::move_drafts_for_path(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Script],
|
||||
p_path,
|
||||
&ns.path,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
|
||||
ns.path,
|
||||
@@ -2403,32 +2389,19 @@ async fn create_script_internal<'c>(
|
||||
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !skip_draft_deletion {
|
||||
// See the matching branch above — only wipe the deployer's own
|
||||
// draft (plus the legacy NULL-email row).
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
|
||||
AND (email = $3 OR email IS NULL)",
|
||||
ns.path,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else if !skip_draft_deletion {
|
||||
// See the matching branch above — only wipe the deployer's own
|
||||
// draft (plus the legacy NULL-email row).
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
|
||||
AND (email = $3 OR email IS NULL)",
|
||||
ns.path,
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
// Every deploy, not only a new script: an archived script's draft can be moved away
|
||||
// (`move_draft` ignores archived rows), and unarchiving redeploys at the same path,
|
||||
// where a route left behind would send the live script's saves to the moved draft.
|
||||
windmill_common::user_drafts::clear_draft_moves_from(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::Script],
|
||||
&ns.path,
|
||||
p_path_opt.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if p_hashes.is_some() && !p_hashes.unwrap().is_empty() {
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -3088,24 +3061,17 @@ async fn get_script_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<ScriptHistory>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:read:{}", path))?;
|
||||
// Unasked-for, this listing stays whole: the deployment-history panels, the restart
|
||||
// picker and the CLI all read it without paging. The diff picker asks for a page.
|
||||
let (per_page, offset) = paginate_optional(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let query_result = sqlx::query!(
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
|
||||
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
|
||||
WHERE s.workspace_id = $1 AND s.path = $2
|
||||
ORDER by s.created_at DESC
|
||||
LIMIT $3 OFFSET $4",
|
||||
ORDER by s.created_at DESC",
|
||||
w_id,
|
||||
path,
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
@@ -3117,7 +3083,6 @@ async fn get_script_history(
|
||||
script_hash: ScriptHash(row.hash),
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_at: Some(row.created_at),
|
||||
created_by: Some(row.created_by),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Json(result));
|
||||
@@ -3132,7 +3097,7 @@ async fn get_latest_version(
|
||||
check_scopes(&authed, || format!("scripts:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let row_o = sqlx::query!(
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
|
||||
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
|
||||
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
|
||||
WHERE s.workspace_id = $1 AND s.path = $2
|
||||
ORDER by s.created_at DESC LIMIT 1",
|
||||
@@ -3148,7 +3113,6 @@ async fn get_latest_version(
|
||||
script_hash: ScriptHash(row.hash),
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_at: Some(row.created_at),
|
||||
created_by: Some(row.created_by),
|
||||
};
|
||||
return Ok(Json(Some(result)));
|
||||
} else {
|
||||
|
||||
@@ -42,7 +42,6 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
@@ -60,11 +59,10 @@ 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,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING,
|
||||
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,
|
||||
MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
|
||||
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
|
||||
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
|
||||
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
|
||||
@@ -169,6 +167,22 @@ pub fn global_service() -> Router {
|
||||
"/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(
|
||||
"/external_instance_pg/databases",
|
||||
get(list_external_instance_pg_databases),
|
||||
)
|
||||
.route(
|
||||
"/external_instance_pg/databases/{name}",
|
||||
post(create_external_instance_pg_database).delete(drop_external_instance_pg_database),
|
||||
)
|
||||
.route(
|
||||
"/setup_custom_instance_pg_database/{name}",
|
||||
post(setup_custom_instance_pg_database),
|
||||
@@ -878,6 +892,14 @@ pub async fn set_global_setting_internal(
|
||||
)));
|
||||
}
|
||||
|
||||
if key == EXTERNAL_INSTANCE_PG_SETTING {
|
||||
return windmill_common::external_instance_pg::write_external_instance_pg_setting(
|
||||
db,
|
||||
Some(&value),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
run_setting_pre_write_hook(db, &key, &value).await?;
|
||||
|
||||
match value {
|
||||
@@ -1196,12 +1218,6 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
MAX_TOKEN_EXPIRATION_DAYS_SETTING => {
|
||||
windmill_common::global_settings::parse_max_token_expiration_days(Some(value))
|
||||
.map_err(|e| {
|
||||
error::Error::BadRequest(format!("{MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}"))
|
||||
})?;
|
||||
}
|
||||
INSTANCE_BANNER_SETTING => {
|
||||
match value {
|
||||
// Clearing (delete row) is handled by the caller; allow it through.
|
||||
@@ -1265,7 +1281,7 @@ async fn set_instance_config(
|
||||
let desired_map = desired.global_settings.to_settings_map();
|
||||
if !desired_map.is_empty() {
|
||||
let current_map = current.global_settings.to_settings_map();
|
||||
let settings_diff =
|
||||
let mut settings_diff =
|
||||
instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge);
|
||||
let ai_config_changed = settings_diff
|
||||
.upserts
|
||||
@@ -1294,8 +1310,15 @@ async fn set_instance_config(
|
||||
}
|
||||
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
if key != EXTERNAL_INSTANCE_PG_SETTING {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
}
|
||||
}
|
||||
windmill_common::external_instance_pg::write_external_instance_pg_from_diff(
|
||||
&db,
|
||||
&mut settings_diff,
|
||||
)
|
||||
.await?;
|
||||
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
@@ -1363,12 +1386,6 @@ pub async fn get_global_setting(
|
||||
&& key != HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING
|
||||
&& key != WS_BASE_URL_SETTING
|
||||
&& key != INSTANCE_BANNER_SETTING
|
||||
// The token form reads it to stop offering expirations the server would shorten.
|
||||
&& key != MAX_TOKEN_EXPIRATION_DAYS_SETTING
|
||||
// Whoever is wiring up an MCP client reads it to know whether a URL-borne token
|
||||
// would be refused, and they are usually not a superadmin. Not a secret: pointing
|
||||
// any MCP client at the instance discovers the same answer.
|
||||
&& key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING
|
||||
{
|
||||
require_super_admin(&db, &authed).await?;
|
||||
}
|
||||
@@ -1660,6 +1677,8 @@ struct CustomInstanceDb {
|
||||
tag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
used_by_workspaces: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Serialize, Default)]
|
||||
@@ -1700,7 +1719,15 @@ async fn list_custom_instance_pg_databases(
|
||||
))
|
||||
})?;
|
||||
|
||||
if windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? {
|
||||
// Which workspace reserved a fork copy is nobody else's business: it would enumerate every
|
||||
// pending fork on the instance.
|
||||
for entry in result.values_mut() {
|
||||
entry.workspace_id = None;
|
||||
}
|
||||
return Ok(Json(result));
|
||||
}
|
||||
{
|
||||
// Enrich each database with the list of workspaces referencing it through
|
||||
// either a ducklake catalog or a datatable database whose resource_type is
|
||||
// 'instance'. Not stored in DB to avoid drift.
|
||||
@@ -1756,6 +1783,135 @@ 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(Serialize)]
|
||||
struct ExternalInstancePgDatabase {
|
||||
#[serde(flatten)]
|
||||
status: windmill_common::instance_config::CustomInstanceDb,
|
||||
used_by_workspaces: Vec<String>,
|
||||
}
|
||||
|
||||
async fn list_external_instance_pg_databases(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<std::collections::BTreeMap<String, ExternalInstancePgDatabase>> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
let databases = windmill_common::external_instance_pg::external_instance_databases(&db).await?;
|
||||
let mut usages =
|
||||
windmill_common::external_instance_pg::external_instance_database_usages(&db).await?;
|
||||
Ok(Json(
|
||||
databases
|
||||
.into_iter()
|
||||
.map(|(name, status)| {
|
||||
let used_by_workspaces = usages.remove(&name).unwrap_or_default();
|
||||
(
|
||||
name,
|
||||
ExternalInstancePgDatabase {
|
||||
status,
|
||||
used_by_workspaces: used_by_workspaces.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_external_instance_pg_database(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(dbname): Path<String>,
|
||||
Json(body): Json<SetupCustomInstanceDbBody>,
|
||||
) -> JsonResult<()> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
let tag = body.tag.as_deref().unwrap_or("datatable");
|
||||
windmill_common::external_instance_pg::create_external_instance_database_unchecked(
|
||||
&db, &dbname, tag, None,
|
||||
)
|
||||
.await?;
|
||||
windmill_audit::audit_oss::audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.create_external_instance_pg_database",
|
||||
windmill_audit::ActionKind::Create,
|
||||
"global",
|
||||
Some(&authed.email),
|
||||
Some([("dbname", dbname.as_str()), ("tag", tag)].into()),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn drop_external_instance_pg_database(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(dbname): Path<String>,
|
||||
) -> JsonResult<()> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
// A data table naming a dropped database fails on every job, far from the drop that caused it.
|
||||
windmill_common::external_instance_pg::drop_external_instance_database_unchecked(
|
||||
&db, &dbname, None,
|
||||
)
|
||||
.await?;
|
||||
windmill_audit::audit_oss::audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
"settings.drop_external_instance_pg_database",
|
||||
windmill_audit::ActionKind::Delete,
|
||||
"global",
|
||||
Some(&authed.email),
|
||||
Some([("dbname", dbname.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetupCustomInstanceDbBody {
|
||||
tag: Option<String>,
|
||||
@@ -1767,18 +1923,38 @@ async fn setup_custom_instance_pg_database(
|
||||
Path(dbname): Path<String>,
|
||||
Json(body): Json<SetupCustomInstanceDbBody>,
|
||||
) -> JsonResult<CustomInstanceDb> {
|
||||
// Before anything is recorded: the status written below replaces the registry entry, and with it
|
||||
// the workspace a fork copy is reserved for.
|
||||
require_super_admin(&db, &authed).await?;
|
||||
let mut logs = CustomInstanceDbLogs::default();
|
||||
let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await;
|
||||
let success = result.is_ok();
|
||||
let error = result.err().map(|e| e.to_string());
|
||||
let status =
|
||||
CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] };
|
||||
let status = CustomInstanceDb {
|
||||
logs,
|
||||
success,
|
||||
error,
|
||||
tag: body.tag,
|
||||
used_by_workspaces: vec![],
|
||||
workspace_id: None,
|
||||
};
|
||||
let status_json = serde_json::to_value(&status).map_err(to_anyhow)?;
|
||||
// Save that the database was setup successfully
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#,
|
||||
json!({ dbname: status_json })
|
||||
).execute(&db).await?;
|
||||
// The fork reservation is carried over inside the write, from whatever the row holds then: a
|
||||
// rename migrating it while the setup above ran would otherwise be overwritten with the value
|
||||
// this request started from, stranding the copy under the archived workspace.
|
||||
let saved = sqlx::query_scalar::<_, serde_json::Value>(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}',
|
||||
COALESCE(value->'databases', '{}'::jsonb)
|
||||
|| jsonb_build_object($1::text, $2::jsonb || jsonb_build_object(
|
||||
'workspace_id', value->'databases'->$1::text->'workspace_id')))
|
||||
WHERE name = 'custom_instance_pg_databases'
|
||||
RETURNING value->'databases'->$1::text"#,
|
||||
)
|
||||
.bind(&dbname)
|
||||
.bind(&status_json)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let status: CustomInstanceDb = serde_json::from_value(saved).map_err(to_anyhow)?;
|
||||
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
@@ -47,10 +47,7 @@ use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::audit::AuditAuthor;
|
||||
use windmill_common::auth::{hash_token, safe_token_prefix, TOKEN_PREFIX_LEN};
|
||||
use windmill_common::global_settings::{
|
||||
load_value_from_global_settings, parse_max_token_expiration_days,
|
||||
AUTOMATE_USERNAME_CREATION_SETTING, MAX_TOKEN_EXPIRATION_DAYS_SETTING,
|
||||
};
|
||||
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
|
||||
use windmill_common::oauth2::InstanceEvent;
|
||||
use windmill_common::per_minute_counter::PerMinuteCounter;
|
||||
use windmill_common::users::truncate_token;
|
||||
@@ -171,10 +168,7 @@ pub fn make_unauthed_service() -> Router {
|
||||
.route("/logout", post(logout).get(logout))
|
||||
.route("/is_first_time_setup", get(is_first_time_setup))
|
||||
.route("/request_password_reset", post(request_password_reset))
|
||||
.route(
|
||||
"/login_link/{token}",
|
||||
get(consume_login_link).post(confirm_login_link),
|
||||
)
|
||||
.route("/login_link/{token}", get(consume_login_link))
|
||||
.route("/is_smtp_configured", get(is_smtp_configured))
|
||||
.route(
|
||||
"/is_password_login_disabled",
|
||||
@@ -3098,68 +3092,11 @@ pub async fn create_guest_session_token<'c>(
|
||||
|
||||
// create_token_internal is re-exported from windmill-api-auth above
|
||||
|
||||
/// Applies the instance-wide ceiling on how long a token a caller picks the lifetime of may
|
||||
/// live (`create_token`, and `impersonate` for superadmins), returning the expiration to store:
|
||||
/// the requested one while it fits, the ceiling otherwise, and the ceiling as well when none was
|
||||
/// requested. Only the stored expiration is capped: tokens already stored when the setting is
|
||||
/// turned on or lowered keep theirs, since the auth lookup never reads the setting.
|
||||
///
|
||||
/// It shortens rather than refuses because most callers do not comply on their own. The CLI
|
||||
/// authorization page, `wmill user create-token` and the editor's language-server token each
|
||||
/// pick a lifetime, often none at all, without reading the setting (and CLIs already installed
|
||||
/// never will), so refusing would break logging in and the editor instead of the long-lived
|
||||
/// tokens the setting is aimed at.
|
||||
///
|
||||
/// Read from `global_settings` on each call rather than cached: token creation is rare
|
||||
/// enough that the round trip costs nothing, and the ceiling is then never served stale.
|
||||
///
|
||||
/// A token owned by a service account is exempt: in the workspace the token names, or in any
|
||||
/// workspace for a workspace-less token, which has none to match. Service accounts are the
|
||||
/// identity automation that needs a long-lived credential runs as. The cost is that any
|
||||
/// workspace admin can create and impersonate one to hold an uncapped token, so the ceiling
|
||||
/// bounds personal tokens rather than what an admin can obtain.
|
||||
async fn cap_token_expiration(
|
||||
db: &DB,
|
||||
owner_email: &str,
|
||||
workspace_id: Option<&str>,
|
||||
requested: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
|
||||
let value = load_value_from_global_settings(db, MAX_TOKEN_EXPIRATION_DAYS_SETTING).await?;
|
||||
let max_days = match parse_max_token_expiration_days(value.as_ref()) {
|
||||
Ok(Some(max_days)) => max_days,
|
||||
Ok(None) => return Ok(requested),
|
||||
// Both write paths reject this, so only a row written around them gets here.
|
||||
Err(e) => {
|
||||
tracing::warn!("ignoring {MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}");
|
||||
return Ok(requested);
|
||||
}
|
||||
};
|
||||
let max = chrono::Utc::now() + chrono::Duration::days(max_days);
|
||||
|
||||
let is_service_account = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true
|
||||
AND ($2::varchar IS NULL OR workspace_id = $2))",
|
||||
owner_email,
|
||||
workspace_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if is_service_account {
|
||||
return Ok(requested);
|
||||
}
|
||||
|
||||
Ok(Some(match requested {
|
||||
Some(expiration) if expiration < max => expiration,
|
||||
_ => max,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
OptJobAuthed { job_id, .. }: OptJobAuthed,
|
||||
Json(mut token_config): Json<NewToken>,
|
||||
Json(token_config): Json<NewToken>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
forbid_elevated_job_token(&db, &authed.email, job_id).await?;
|
||||
check_token_create_rate_limit(&authed.username)?;
|
||||
@@ -3181,14 +3118,6 @@ async fn create_token(
|
||||
|
||||
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
|
||||
|
||||
token_config.expiration = cap_token_expiration(
|
||||
&db,
|
||||
&authed.email,
|
||||
token_config.workspace_id.as_deref(),
|
||||
token_config.expiration,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
|
||||
@@ -3245,7 +3174,6 @@ async fn impersonate(
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
let expiration = cap_token_expiration(&db, &impersonated, None, new_token.expiration).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -3257,7 +3185,7 @@ async fn impersonate(
|
||||
plaintext as Option<&str>,
|
||||
impersonated,
|
||||
new_token.label,
|
||||
expiration,
|
||||
new_token.expiration,
|
||||
is_super_admin
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
@@ -3267,7 +3195,7 @@ async fn impersonate(
|
||||
&mut *tx,
|
||||
&t_hash,
|
||||
new_token.label.as_deref(),
|
||||
expiration,
|
||||
new_token.expiration,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3287,12 +3215,9 @@ async fn impersonate(
|
||||
}
|
||||
|
||||
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
|
||||
// Long enough for a link sent by email to still work when it is read. `require_login_type` is
|
||||
// only checked at mint, so a much longer cap would need re-checking it when the link is opened.
|
||||
const LOGIN_LINK_MAX_TTL_S: u32 = 7200;
|
||||
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
|
||||
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
|
||||
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
|
||||
const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewLoginLink {
|
||||
@@ -3303,9 +3228,6 @@ pub struct NewLoginLink {
|
||||
/// account it created can require `pending_oauth`, so the link stops working once the
|
||||
/// owner has set a password or signed in with a provider.
|
||||
pub require_login_type: Option<String>,
|
||||
/// Hand out a page that signs in only when its button is clicked. Mail scanners open links
|
||||
/// on delivery, and opening the plain link spends it, so a link sent by email sets this.
|
||||
pub confirm: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -3456,12 +3378,11 @@ async fn create_login_link(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let base_url = (**BASE_URL.load()).clone();
|
||||
let url = if nl.confirm.unwrap_or(false) {
|
||||
format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}")
|
||||
} else {
|
||||
format!("{base_url}/api/auth/login_link/{token}")
|
||||
};
|
||||
let url = format!(
|
||||
"{}/api/auth/login_link/{}",
|
||||
(**BASE_URL.load()).clone(),
|
||||
token
|
||||
);
|
||||
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
|
||||
}
|
||||
|
||||
@@ -3707,45 +3628,19 @@ async fn consume_login_link(
|
||||
Path(token): Path<String>,
|
||||
Query(query): Query<LoginLinkQuery>,
|
||||
) -> Result<Response> {
|
||||
let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?;
|
||||
Ok(login_link_redirect(location))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginLinkLocation {
|
||||
location: String,
|
||||
}
|
||||
|
||||
/// The confirmation page's click. It answers with where to go rather than redirecting, and the
|
||||
/// page navigates there itself.
|
||||
async fn confirm_login_link(
|
||||
headers: axum::http::HeaderMap,
|
||||
cookies: Cookies,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(token): Path<String>,
|
||||
) -> JsonResult<LoginLinkLocation> {
|
||||
let location = redeem_login_link(&headers, cookies, &db, &token, None).await?;
|
||||
Ok(Json(LoginLinkLocation { location }))
|
||||
}
|
||||
|
||||
/// Spends the link and sets the session cookie, returning the post-login destination; or
|
||||
/// returns the explanation page, with no session, when the link cannot be used.
|
||||
async fn redeem_login_link(
|
||||
headers: &axum::http::HeaderMap,
|
||||
cookies: Cookies,
|
||||
db: &DB,
|
||||
token: &str,
|
||||
requested_rd: Option<String>,
|
||||
) -> Result<String> {
|
||||
let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"));
|
||||
let bounce = |reason: &str| {
|
||||
Ok(login_link_redirect(format!(
|
||||
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
|
||||
)))
|
||||
};
|
||||
if token.len() != 32 {
|
||||
return bounce("invalid");
|
||||
}
|
||||
let t_hash = hash_token(token);
|
||||
let t_hash = hash_token(&token);
|
||||
// The account is unknown until the row is read, so only the global and per-IP tiers
|
||||
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
|
||||
windmill_common::login_rate_limit::check_and_increment_login_attempt(
|
||||
headers,
|
||||
&headers,
|
||||
&t_hash[..TOKEN_PREFIX_LEN],
|
||||
)?;
|
||||
|
||||
@@ -3812,10 +3707,11 @@ async fn redeem_login_link(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(link
|
||||
let rd = link
|
||||
.rd
|
||||
.or_else(|| same_origin_rd(requested_rd))
|
||||
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()))
|
||||
.or_else(|| same_origin_rd(query.rd))
|
||||
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
|
||||
Ok(login_link_redirect(rd))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -4049,7 +3945,6 @@ async fn update_token_label(
|
||||
AND NOT starts_with(label, 'embed_app:')
|
||||
AND NOT starts_with(label, 'sdk_app:')
|
||||
AND NOT starts_with(label, 'impersonation:')
|
||||
AND NOT starts_with(label, 'cli-login:')
|
||||
))
|
||||
RETURNING token_prefix",
|
||||
req.label.as_deref(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the ACL planner comes from: the enterprise one, or a refusal.
|
||||
//!
|
||||
//! Data table roles are an Enterprise Edition feature, and so is everything here — reading who
|
||||
//! owns what included. `private` alone is not that edition — community builds carry it — so the
|
||||
//! planner is behind `enterprise` as well.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::datatable_acl_ee::plan_statements;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) fn ensure_datatable_acl_available() -> windmill_common::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
use {
|
||||
crate::datatable_acl::{AclChange, AclPlan, AclTarget, CatalogFacts},
|
||||
windmill_common::{datatable_roles_oss::datatable_roles_unavailable, error::Result},
|
||||
};
|
||||
|
||||
/// Checked first by every ACL route, before anything is read or connected to.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn ensure_datatable_acl_available() -> Result<()> {
|
||||
Err(datatable_roles_unavailable())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) fn plan_statements(
|
||||
_target: &AclTarget,
|
||||
_change: &AclChange,
|
||||
_dbname: &str,
|
||||
_pg_role: &str,
|
||||
_facts: &CatalogFacts,
|
||||
) -> Result<AclPlan> {
|
||||
Err(datatable_roles_unavailable())
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! to keep that file focused on core workspace configuration.
|
||||
|
||||
use crate::workspaces::{
|
||||
is_instance_datatable, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison,
|
||||
managed_datatable_kind, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison,
|
||||
PgDumpOptions,
|
||||
};
|
||||
|
||||
@@ -1556,7 +1556,9 @@ async fn generate_initial_datatable_migration(
|
||||
// without what a replay elsewhere cannot run: the replaying user owns none of this
|
||||
// database's objects, and the grants Windmill plants in an instance database (`ALTER
|
||||
// DEFAULT PRIVILEGES FOR ROLE ...`) fail even replaying onto the same server.
|
||||
let no_acl = is_instance_datatable(&db, &w_id, &datatable_name).await?;
|
||||
let no_acl = managed_datatable_kind(&db, &w_id, &datatable_name)
|
||||
.await?
|
||||
.is_some();
|
||||
let dump_file = pg_dump_database(
|
||||
&pg_db,
|
||||
PgDumpOptions {
|
||||
|
||||
@@ -65,3 +65,15 @@ pub(crate) async fn ensure_reaches_datatable(
|
||||
) -> Result<()> {
|
||||
roles::ensure_reaches_datatable(db, w_id, datatable_name, authed).await
|
||||
}
|
||||
|
||||
/// [`ensure_reaches_datatable`] against an entry already resolved, for a caller that goes on to
|
||||
/// connect from that same entry.
|
||||
pub(crate) async fn ensure_reaches_governing_datatable(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable_name: &str,
|
||||
governing: &GoverningDatatable,
|
||||
authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
roles::ensure_reaches_governing_datatable(db, w_id, datatable_name, governing, authed).await
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
|
||||
#[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,
|
||||
ensure_governs_datatable, ensure_reaches_datatable, ensure_reaches_governing_datatable,
|
||||
get_datatable_permissions, list_usable_datatable_roles, set_datatable_permissions,
|
||||
usable_datatable_roles,
|
||||
};
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
@@ -56,6 +57,20 @@ mod ce {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_reaches_governing_datatable(
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_datatable_name: &str,
|
||||
governing: &GoverningDatatable,
|
||||
_authed: &ApiAuthed,
|
||||
) -> Result<()> {
|
||||
if governing.datatable.permissions.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
// The routes stay registered so the API has one shape; each answers after authentication,
|
||||
// before anything is read.
|
||||
|
||||
@@ -70,4 +85,28 @@ mod ce {
|
||||
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,6 +1,8 @@
|
||||
#[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;
|
||||
@@ -12,5 +14,8 @@ pub mod workspaces_oss;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod workspaces_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_acl_ee;
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub mod datatable_permissions_ee;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,10 +110,25 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// A fork copy reserved for the old id would otherwise be unreachable: its creator cannot
|
||||
// import into it or finish its fork under the new id, and nothing else would ever drop it.
|
||||
sqlx::query(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (
|
||||
SELECT COALESCE(jsonb_object_agg(k, CASE WHEN v->>'workspace_id' = $1
|
||||
THEN jsonb_set(v, '{workspace_id}', to_jsonb($2::text)) ELSE v END), '{}'::jsonb)
|
||||
FROM jsonb_each(COALESCE(value->'databases', '{}'::jsonb)) AS e(k, v)
|
||||
))
|
||||
WHERE name = 'custom_instance_pg_databases'"#,
|
||||
)
|
||||
.bind(&old_id)
|
||||
.bind(&rw.new_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
@@ -382,13 +397,6 @@ pub(crate) async fn change_workspace_id(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating favorite table");
|
||||
sqlx::query!(
|
||||
@@ -1416,9 +1424,7 @@ pub async fn drop_forked_datatable_databases(
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::Instance
|
||||
{
|
||||
if database.resource_type.is_windmill_managed() {
|
||||
let db_to_drop = &database.resource_path;
|
||||
if !db_to_drop.starts_with("wm_fork_") {
|
||||
errors.push(format!(
|
||||
@@ -1427,7 +1433,64 @@ pub async fn drop_forked_datatable_databases(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await {
|
||||
// The fork's own entry is what is going away; anything else still reaching the copy,
|
||||
// a child fork's pointer at this entry included, keeps it. The lock keeps a child fork
|
||||
// from gaining such a pointer before the drop.
|
||||
let dropped = async {
|
||||
let mut tx = db.begin().await?;
|
||||
// The three locks a settings save takes, in its order: this workspace's data
|
||||
// tables, its settings row, and the database itself. Without them a save could
|
||||
// rename this entry, or point another one here, either side of the check below.
|
||||
windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?;
|
||||
sqlx::query("SELECT 1 FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE")
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::datatable_roles::lock_instance_databases_governance(
|
||||
&mut tx,
|
||||
[db_to_drop.as_str()],
|
||||
)
|
||||
.await?;
|
||||
if database.resource_type
|
||||
== windmill_common::workspaces::DataTableCatalogResourceType::ExternalInstance
|
||||
{
|
||||
windmill_common::external_instance_pg::drop_external_instance_database_unchecked(
|
||||
&db,
|
||||
db_to_drop,
|
||||
Some((&w_id, dt_name)),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let uses = windmill_common::workspaces::managed_database_uses(
|
||||
&mut tx,
|
||||
windmill_common::workspaces::DataTableCatalogResourceType::Instance,
|
||||
db_to_drop,
|
||||
Some((&w_id, dt_name)),
|
||||
)
|
||||
.await?;
|
||||
if !uses.is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"it is still used by {}",
|
||||
uses.join(", ")
|
||||
)));
|
||||
}
|
||||
windmill_common::drop_custom_instance_database(&db, db_to_drop).await?;
|
||||
}
|
||||
// The entry goes with the database: a fork this one is cloned into afterwards must
|
||||
// not inherit a pointer at a data table whose database is gone.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2]
|
||||
WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(dt_name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = dropped {
|
||||
errors.push(format!(
|
||||
"Could not drop instance database '{}' for datatable://{}: {}",
|
||||
db_to_drop, dt_name, e
|
||||
|
||||
+541
-164
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.815.0
|
||||
version: 1.814.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -529,30 +529,6 @@ paths:
|
||||
responses:
|
||||
"302":
|
||||
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
|
||||
post:
|
||||
security: []
|
||||
summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go
|
||||
operationId: confirmLoginLink
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- location
|
||||
properties:
|
||||
location:
|
||||
type: string
|
||||
|
||||
/auth/reset_password:
|
||||
post:
|
||||
@@ -1596,6 +1572,104 @@ 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/external_instance_pg/databases:
|
||||
get:
|
||||
summary: Lists the databases Windmill created on the external instance cluster, with the workspaces whose data tables use each
|
||||
operationId: listExternalInstancePgDatabases
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: databases by name
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/CustomInstanceDb"
|
||||
|
||||
/settings/external_instance_pg/databases/{name}:
|
||||
post:
|
||||
summary: Creates a database on the external instance cluster (enterprise edition only)
|
||||
operationId: createExternalInstancePgDatabase
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tag:
|
||||
$ref: "#/components/schemas/CustomInstanceDbTag"
|
||||
responses:
|
||||
"200":
|
||||
description: database created
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
delete:
|
||||
summary: Drops a database Windmill created on the external instance cluster, refused while a data table uses it
|
||||
operationId: dropExternalInstancePgDatabase
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: database dropped
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/settings/list_custom_instance_pg_databases:
|
||||
post:
|
||||
summary: Returns the set-up statuses of custom instance pg databases
|
||||
@@ -4090,13 +4164,9 @@ paths:
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
add_admins_and_developers_to_forks:
|
||||
type: boolean
|
||||
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
|
||||
required:
|
||||
- workspace_id
|
||||
- guest_access_enabled
|
||||
- add_admins_and_developers_to_forks
|
||||
|
||||
/w/{workspace}/workspaces/get_settings:
|
||||
get:
|
||||
@@ -4187,9 +4257,6 @@ paths:
|
||||
guest_jwt_jwks_url:
|
||||
type: string
|
||||
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
|
||||
add_admins_and_developers_to_forks:
|
||||
type: boolean
|
||||
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -5261,7 +5328,7 @@ paths:
|
||||
type: string
|
||||
resource_type:
|
||||
type: string
|
||||
enum: [postgres, instance]
|
||||
enum: [postgres, instance, external_instance]
|
||||
resource_path:
|
||||
type: string
|
||||
governing_workspace_id:
|
||||
@@ -5325,6 +5392,97 @@ paths:
|
||||
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
|
||||
@@ -5428,6 +5586,21 @@ paths:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: query
|
||||
description: list only this data table; each listed data table opens a connection to its database
|
||||
schema:
|
||||
type: string
|
||||
- name: role_for
|
||||
in: query
|
||||
description: the data table `role` applies to; every other one is listed as its default role
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
description: the role to list `role_for` as; refused, in that entry's `error`, if the caller may not use it
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: table metadata of all datatables
|
||||
@@ -5461,6 +5634,11 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: role
|
||||
in: query
|
||||
description: the data table role to read the table as; defaults to the data table's default role
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schema of one datatable table
|
||||
@@ -6320,39 +6498,6 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks:
|
||||
post:
|
||||
summary: choose whether new forks of this workspace start with its admins and developers
|
||||
description: >-
|
||||
When on, every fork created from this workspace gets the workspace's admins and
|
||||
developers as members, with the role they hold here; operators, disabled users and
|
||||
service accounts are left out. The setting is copied into each fork, so forks of a
|
||||
fork follow it too. Off by default. Workspace-admin gated.
|
||||
operationId: editAddAdminsAndDevelopersToForks
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Whether new forks start with this workspace's admins and developers
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
add_admins_and_developers_to_forks:
|
||||
type: boolean
|
||||
required:
|
||||
- add_admins_and_developers_to_forks
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_jwt_key:
|
||||
post:
|
||||
summary: set the key guest JWTs are verified against for this workspace
|
||||
@@ -6817,7 +6962,7 @@ paths:
|
||||
type: string
|
||||
expires_in_s:
|
||||
type: integer
|
||||
description: link lifetime in seconds, at most 7200 (default 600)
|
||||
description: link lifetime in seconds, at most 900 (default 600)
|
||||
rd:
|
||||
type: string
|
||||
description: same-origin path the browser lands on after login (default /user/workspaces)
|
||||
@@ -6827,12 +6972,6 @@ paths:
|
||||
mint only while the account still has this login type (for example
|
||||
pending_oauth), so a link stops working once the owner has set a password
|
||||
or signed in with a provider
|
||||
confirm:
|
||||
type: boolean
|
||||
description: >-
|
||||
return a /user/login_link page that signs in only when its button is
|
||||
clicked, instead of a link spent by opening it; set it for links sent by
|
||||
email, which mail scanners open on delivery (default false)
|
||||
responses:
|
||||
"201":
|
||||
description: login link minted
|
||||
@@ -10392,49 +10531,8 @@ paths:
|
||||
current_timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
path:
|
||||
type: string
|
||||
description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to."
|
||||
required: [status, current_timestamp]
|
||||
|
||||
/w/{workspace}/drafts/move/{kind}/{path}:
|
||||
post:
|
||||
summary: move the current user's draft-only item to another path
|
||||
description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it.
|
||||
operationId: moveDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
description: script, flow, app or raw_app only.
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, flow, app, raw_app]
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
new_path:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
description: Also restate the draft's summary.
|
||||
required: [new_path]
|
||||
responses:
|
||||
"200":
|
||||
description: move result
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
|
||||
post:
|
||||
summary: resolve a legacy (workspace-level) draft (admin only)
|
||||
@@ -10945,8 +11043,6 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
"200":
|
||||
description: script history
|
||||
@@ -12352,8 +12448,6 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
tags:
|
||||
- flow
|
||||
responses:
|
||||
@@ -14232,8 +14326,6 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
"200":
|
||||
description: app history
|
||||
@@ -14501,9 +14593,9 @@ paths:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
application/json:
|
||||
text/plain:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDeployed"
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/create_raw_source:
|
||||
post:
|
||||
@@ -14643,9 +14735,9 @@ paths:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
application/json:
|
||||
text/plain:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDeployed"
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/update_raw/{path}:
|
||||
post:
|
||||
@@ -14699,9 +14791,9 @@ paths:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
application/json:
|
||||
text/plain:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDeployed"
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/custom_path_exists/{custom_path}:
|
||||
get:
|
||||
@@ -27749,13 +27841,6 @@ components:
|
||||
draft_saved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
draft_base:
|
||||
type: string
|
||||
description: |
|
||||
The deployed version the draft forked from, as text whatever the
|
||||
kind (script hash, flow version id, app version id). Compare to the
|
||||
deployed head to tell a draft that is behind. Absent when there is
|
||||
no draft or it was never forked from a deploy.
|
||||
no_deployed:
|
||||
type: boolean
|
||||
draft:
|
||||
@@ -29651,11 +29736,6 @@ components:
|
||||
type: string
|
||||
deployment_msg:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_by:
|
||||
type: string
|
||||
required:
|
||||
- script_hash
|
||||
|
||||
@@ -33718,6 +33798,44 @@ 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]
|
||||
@@ -33778,6 +33896,255 @@ components:
|
||||
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:
|
||||
@@ -33801,7 +34168,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted.
|
||||
description: Workspaces that reference this database through a ducklake catalog or a datatable database of the kind being listed — 'instance' for the instance databases endpoint, 'external_instance' for the external cluster one. Computed at request time, not persisted, and only returned to superadmins.
|
||||
workspace_id:
|
||||
type: string
|
||||
description: The workspace a member created this database for as a fork copy. Only that workspace can import into it or point a fork at it.
|
||||
|
||||
NewSqsTrigger:
|
||||
type: object
|
||||
@@ -34860,9 +35230,6 @@ components:
|
||||
nullable: true
|
||||
disabled:
|
||||
type: boolean
|
||||
is_service_account:
|
||||
type: boolean
|
||||
description: Whether this membership is a service account.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -35552,20 +35919,6 @@ components:
|
||||
- raw_app
|
||||
|
||||
|
||||
AppDeployed:
|
||||
type: object
|
||||
description: "What a deploy of an existing app answers with. `version` is the one this call wrote, which is what an editor pins as the fork base of the draft it starts next: reading the head back afterwards cannot tell it from a deploy that landed beside it. A metadata-only update writes none and reports the head it kept."
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: Where the app now lives, which differs from the request path on a rename.
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- path
|
||||
- version
|
||||
|
||||
AppHistory:
|
||||
type: object
|
||||
properties:
|
||||
@@ -35573,11 +35926,6 @@ components:
|
||||
type: integer
|
||||
deployment_msg:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_by:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
|
||||
@@ -35647,8 +35995,6 @@ components:
|
||||
format: date-time
|
||||
deployment_msg:
|
||||
type: string
|
||||
created_by:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- created_at
|
||||
@@ -35862,6 +36208,7 @@ components:
|
||||
enum:
|
||||
- postgresql
|
||||
- instance
|
||||
- external_instance
|
||||
resource_path:
|
||||
type: string
|
||||
required:
|
||||
@@ -35945,7 +36292,17 @@ components:
|
||||
|
||||
DataTableTables:
|
||||
type: object
|
||||
required: [datatable_name, schemas]
|
||||
required:
|
||||
[
|
||||
datatable_name,
|
||||
schemas,
|
||||
instance,
|
||||
permissioned,
|
||||
usable_roles,
|
||||
default_role,
|
||||
can_create_schema,
|
||||
creatable_schemas,
|
||||
]
|
||||
properties:
|
||||
datatable_name:
|
||||
type: string
|
||||
@@ -35958,6 +36315,26 @@ components:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
instance:
|
||||
type: boolean
|
||||
description: on the instance database, the only kind that can be under roles or have its access edited
|
||||
permissioned:
|
||||
type: boolean
|
||||
usable_roles:
|
||||
type: array
|
||||
description: the roles the caller may connect as, by name; empty when not under roles
|
||||
items:
|
||||
type: string
|
||||
default_role:
|
||||
type: string
|
||||
can_create_schema:
|
||||
type: boolean
|
||||
description: whether the role the listing connected as may create schemas
|
||||
creatable_schemas:
|
||||
type: array
|
||||
description: the schemas the role the listing connected as may create in
|
||||
items:
|
||||
type: string
|
||||
|
||||
DataTableTableSchema:
|
||||
type: object
|
||||
|
||||
@@ -69,8 +69,8 @@ use windmill_common::{
|
||||
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{
|
||||
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
|
||||
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
|
||||
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
|
||||
strip_json_nul, Pagination, RunnableKind, StripPath,
|
||||
},
|
||||
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
@@ -274,12 +274,6 @@ pub struct AppHistory {
|
||||
pub version: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_msg: Option<String>,
|
||||
/// Who deployed this version, and when — the diff's version picker names them so
|
||||
/// a reader can tell their own deploys from a teammate's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -573,17 +567,6 @@ pub struct CreateApp {
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
|
||||
/// What a deploy of an existing app answers with. `version` is the one this call wrote,
|
||||
/// which is what an editor pins as the fork base of the draft it starts next: reading the
|
||||
/// head back afterwards cannot tell it from a deploy that landed beside it. A
|
||||
/// metadata-only update writes none and reports the head it kept.
|
||||
#[derive(Serialize)]
|
||||
pub struct AppDeployed {
|
||||
/// Where the app now lives, which differs from the request path on a rename.
|
||||
pub path: String,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct EditApp {
|
||||
pub path: Option<String>,
|
||||
@@ -1237,42 +1220,18 @@ async fn get_app_history(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<AppHistory>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", &path))?;
|
||||
// Unasked-for, this listing stays whole: the deployment-history panel reads it
|
||||
// without paging. The diff picker asks for a page.
|
||||
let (per_page, offset) = paginate_optional(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
// Newest first in deployed order, which is a version's position in `app.versions` and
|
||||
// not its `created_at`: the latter is the deploying transaction's start time, so two
|
||||
// that overlap can carry it in the opposite order from the one they landed in. A row
|
||||
// outside the array never sat in that sequence, so it sorts after the ones that did.
|
||||
// Paging happens before the metadata joins, so a page costs its own rows.
|
||||
let query_result = sqlx::query!(
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
|
||||
av.created_by as created_by, av.created_at as created_at
|
||||
FROM app a
|
||||
JOIN LATERAL (
|
||||
SELECT av2.id, COALESCE(v.ord, 0) AS ord
|
||||
FROM app_version av2
|
||||
LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id
|
||||
WHERE av2.app_id = a.id
|
||||
ORDER BY ord DESC, av2.id DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
) page ON TRUE
|
||||
JOIN app_version av ON av.id = page.id
|
||||
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
|
||||
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
WHERE a.workspace_id = $1 AND a.path = $2
|
||||
ORDER BY page.ord DESC",
|
||||
ORDER BY created_at DESC",
|
||||
w_id,
|
||||
path,
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
).fetch_all(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let result: Vec<AppHistory> = query_result
|
||||
@@ -1281,8 +1240,6 @@ async fn get_app_history(
|
||||
app_id: row.app_id,
|
||||
version: row.version_id,
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_by: Some(row.created_by),
|
||||
created_at: Some(row.created_at),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Json(result));
|
||||
@@ -1296,22 +1253,14 @@ async fn get_latest_version(
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
// The head is the tail of `app.versions` — the version the runtime serves. Deploys
|
||||
// append to it under the app row's lock, whereas `app_version.created_at` is the
|
||||
// deploying transaction's start time, so two that overlap can carry it in either
|
||||
// order and the newest timestamp is then not the one that landed last.
|
||||
let row = sqlx::query!(
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
|
||||
av.created_by as created_by, av.created_at as created_at
|
||||
FROM app a JOIN app_version av
|
||||
ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id
|
||||
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
WHERE a.workspace_id = $1 AND a.path = $2",
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
|
||||
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
|
||||
WHERE a.workspace_id = $1 AND a.path = $2
|
||||
ORDER BY created_at DESC",
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
).fetch_optional(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
@@ -1319,8 +1268,6 @@ async fn get_latest_version(
|
||||
app_id: row.app_id,
|
||||
version: row.version_id,
|
||||
deployment_msg: row.deployment_msg,
|
||||
created_by: Some(row.created_by),
|
||||
created_at: Some(row.created_at),
|
||||
};
|
||||
|
||||
return Ok(Json(Some(result)));
|
||||
@@ -2644,14 +2591,6 @@ async fn create_app_internal<'a>(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
windmill_common::user_drafts::clear_draft_moves_from(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
|
||||
&app.path,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO app
|
||||
(workspace_id, path, summary, policy, versions, custom_path, labels)
|
||||
@@ -3008,7 +2947,7 @@ async fn update_app(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> JsonResult<AppDeployed> {
|
||||
) -> Result<String> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
@@ -3047,7 +2986,7 @@ async fn update_app(
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Json(AppDeployed { path: npath, version: v_id }))
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
}
|
||||
|
||||
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
|
||||
@@ -3061,7 +3000,7 @@ async fn update_app_raw_source(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> JsonResult<AppDeployed> {
|
||||
) -> Result<String> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
@@ -3159,7 +3098,7 @@ async fn update_app_raw_source(
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Json(AppDeployed { path: npath, version: v_id }))
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
}
|
||||
|
||||
/// Whether the caller may create an app at `path` — asked of the database rather
|
||||
@@ -3385,7 +3324,7 @@ async fn update_app_raw<'a>(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
multipart: Multipart,
|
||||
) -> JsonResult<AppDeployed> {
|
||||
) -> Result<String> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
@@ -3433,7 +3372,7 @@ async fn update_app_raw<'a>(
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Json(AppDeployed { path: npath, version: v_id }))
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
}
|
||||
// async fn create_app_internal<'a>(
|
||||
// authed: ApiAuthed,
|
||||
@@ -3810,19 +3749,6 @@ async fn update_app_internal<'a>(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if npath != path {
|
||||
// Everything left at the old path is a draft this deploy didn't consume
|
||||
// — teammates' rows, and the deployer's own when the caller asked us to
|
||||
// keep it. Carry them rather than strand them.
|
||||
windmill_common::user_drafts::move_drafts_for_path(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
|
||||
path,
|
||||
&npath,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -19,7 +19,7 @@ use windmill_common::{
|
||||
error::{Error, Result},
|
||||
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
users::resolve_username_to_email,
|
||||
utils::{check_proper_path, strip_json_nul},
|
||||
utils::strip_json_nul,
|
||||
variables::{build_crypt, encrypt},
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get/{kind}/{*path}", get(get_draft_for_user))
|
||||
.route("/get_own/{kind}/{*path}", get(get_own_draft))
|
||||
.route("/update/{kind}/{*path}", post(update_draft))
|
||||
.route("/move/{kind}/{*path}", post(move_draft))
|
||||
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
|
||||
}
|
||||
|
||||
@@ -321,71 +320,12 @@ pub struct SaveDraftResponse {
|
||||
/// On `saved`: when the change was applied (client remembers it as the
|
||||
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
|
||||
pub current_timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// `saved` only: where the write landed. Differs from the URL path when the item
|
||||
/// had moved away from it; the editor follows it there. Absent when a delete found
|
||||
/// nothing to remove and the caller cannot read the path it moved to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// The version a draft forked from, as the editors write it into `draft.value`.
|
||||
/// Each kind names it differently and only one is ever set.
|
||||
#[derive(Deserialize)]
|
||||
struct DraftBaseVersion {
|
||||
/// Scripts: hex-encoded script hash.
|
||||
#[serde(default)]
|
||||
parent_hash: Option<String>,
|
||||
/// Flows: `flow_version.id`.
|
||||
#[serde(default)]
|
||||
version_id: Option<i64>,
|
||||
/// Apps / raw apps: `app_version.id`.
|
||||
#[serde(default)]
|
||||
parent_version: Option<i64>,
|
||||
}
|
||||
|
||||
impl DraftBaseVersion {
|
||||
/// The base as the `draft.base` column stores it: one opaque text id whatever
|
||||
/// the kind, so a reader compares it to the head without knowing the kind's
|
||||
/// own field name or type.
|
||||
fn as_text(&self, kind: UserDraftItemKind) -> Option<String> {
|
||||
use UserDraftItemKind::*;
|
||||
match kind {
|
||||
Script => self.parent_hash.clone(),
|
||||
Flow => self.version_id.map(|v| v.to_string()),
|
||||
_ => self.parent_version.map(|v| v.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The version this draft forked from, or `None` when it has none — a kind
|
||||
/// that keeps no lineage, a malformed payload, or a draft that was never forked
|
||||
/// from a deploy. Pure: no queries.
|
||||
fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersion> {
|
||||
use UserDraftItemKind::*;
|
||||
if !matches!(kind, Script | Flow | App | RawApp) {
|
||||
return None;
|
||||
}
|
||||
let base = serde_json::from_str::<DraftBaseVersion>(value).ok()?;
|
||||
let has_base = match kind {
|
||||
Script => base
|
||||
.parent_hash
|
||||
.as_deref()
|
||||
.and_then(|h| windmill_common::scripts::to_i64(h).ok())
|
||||
.is_some(),
|
||||
Flow => base.version_id.is_some(),
|
||||
_ => base.parent_version.is_some(),
|
||||
};
|
||||
has_base.then_some(base)
|
||||
}
|
||||
|
||||
/// Apply the current user's draft: non-null `value` upserts, `null` (or
|
||||
/// omitted) deletes. Either way, when the existing row is newer than
|
||||
/// `last_sync` (and `force` is false) the op is skipped and the response is
|
||||
/// `status = conflict` + the server's current timestamp.
|
||||
///
|
||||
/// A save addressed to a path its item moved away from lands where the move took
|
||||
/// the drafts (`draft_move`), unless the caller still has a draft of their own at
|
||||
/// that path. The response names where it landed.
|
||||
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
|
||||
/// upserts, `null` (or omitted) deletes. Either way, when the existing row is
|
||||
/// newer than `last_sync` (and `force` is false) the op is skipped and the
|
||||
/// response is `status = conflict` + the server's current timestamp.
|
||||
async fn update_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -394,7 +334,7 @@ async fn update_draft(
|
||||
Json(req): Json<SaveDraftRequest>,
|
||||
) -> Result<Json<SaveDraftResponse>> {
|
||||
let email = &authed.email;
|
||||
let url_path = path.to_path();
|
||||
let path = path.to_path();
|
||||
// Saving a draft requires write permission on the underlying path. Deleting
|
||||
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
|
||||
// authed user, so they can always discard it even after losing write access
|
||||
@@ -403,68 +343,11 @@ async fn update_draft(
|
||||
// touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone
|
||||
// — they keep the write gate.
|
||||
let is_own_discard = req.value.is_none() && !req.legacy;
|
||||
// `legacy` targets the workspace-level row and is delete-only: an upsert writes the
|
||||
// caller's own row whatever it says. Every read of that rule goes through this.
|
||||
let legacy_delete = req.value.is_none() && req.legacy;
|
||||
|
||||
// Whose row this write is for: the caller's, or the workspace-level one on a legacy
|
||||
// DELETE (`legacy` is delete-only, so an upsert is the caller's own row either way).
|
||||
// It picks both the record that applies — an item's move (`email IS NULL`) covers the
|
||||
// legacy row too, since the same rename carried it — and the draft whose presence
|
||||
// means this path is still the write's own.
|
||||
let owner: Option<&str> = (!legacy_delete).then_some(email.as_str());
|
||||
// The caller's own draft-only move outranks the move of the deployed item. Only the
|
||||
// kinds whose value carries a deploy target are ever recorded as moved, so for the
|
||||
// rest this would be a guaranteed-empty query on the autosave hot path.
|
||||
let moved_to = match kind.typed_path_field() {
|
||||
None => None,
|
||||
Some(_) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT m.new_path FROM draft_move m
|
||||
WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3
|
||||
AND (m.email IS NULL OR m.email = $4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM draft d
|
||||
WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3
|
||||
AND d.email IS NOT DISTINCT FROM $4
|
||||
)
|
||||
ORDER BY m.email IS NULL
|
||||
LIMIT 1"#,
|
||||
&w_id,
|
||||
kind as UserDraftItemKind,
|
||||
url_path,
|
||||
owner,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let path: &str = moved_to.as_deref().unwrap_or(url_path);
|
||||
|
||||
// Everything past here writes, so the gate applies from here on. Answered
|
||||
// without the path when the item moved: it may have gone somewhere the caller
|
||||
// cannot see.
|
||||
if !is_own_discard {
|
||||
match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await {
|
||||
// Naming the move is for whoever was editing the item: it tells them why a
|
||||
// save they were already making stopped landing. Someone who cannot read the
|
||||
// path they addressed gets the plain denial, or the wording itself would
|
||||
// answer whether an item was moved away from a path they only guessed at.
|
||||
Err(Error::NotAuthorized(e)) if moved_to.is_some() => {
|
||||
return Err(Error::NotAuthorized(
|
||||
match require_can_read_path(&authed, &user_db, &w_id, kind, url_path).await {
|
||||
Ok(()) => {
|
||||
"this draft's item was moved to a path you cannot write".to_string()
|
||||
}
|
||||
Err(_) => e,
|
||||
},
|
||||
));
|
||||
}
|
||||
other => other?,
|
||||
}
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
|
||||
}
|
||||
|
||||
let applied = if let Some(value) = &req.value {
|
||||
let applied_at = if let Some(value) = &req.value {
|
||||
// Secret variable values must never sit in `draft.value` in plaintext
|
||||
// (see `encrypt_secret_variable_value`).
|
||||
let serialized = if kind == UserDraftItemKind::Variable {
|
||||
@@ -476,49 +359,19 @@ async fn update_draft(
|
||||
// escape and later make any `->>`/`to_jsonb` extraction raise `22P05`.
|
||||
// Strip it here so a NUL never reaches the column.
|
||||
let serialized = strip_json_nul(&serialized);
|
||||
// `base` is derived here from the value's per-kind field rather than sent
|
||||
// by the client, so every writer (editors, chat, CLI) fills it the same way.
|
||||
// Read from the sanitized text, which is what the value column gets: a NUL in the
|
||||
// lineage field otherwise costs the draft its base (the hash no longer parses),
|
||||
// leaving a draft that reads as up to date whatever the head is.
|
||||
let base = draft_lineage(kind, serialized.as_ref()).and_then(|l| l.as_text(kind));
|
||||
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
|
||||
// when the row is newer than `last_sync`, RETURNING yields nothing.
|
||||
// `created_at` defaults to `now()` but the migration overrides it ($8)
|
||||
// so a migrated draft keeps its original age instead of jumping to top.
|
||||
//
|
||||
// A moved save ($10) carries the path keys its editor had before the move.
|
||||
// One naming the path it addressed ($11) follows to where it landed; a draft
|
||||
// already there keeps the keys the move gave it. A pre-sanitizer NUL escape
|
||||
// in that draft makes `to_jsonb` raise, so it takes the incoming keys.
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
VALUES ($1, $2, $3::text, $4,
|
||||
CASE WHEN $10::bool
|
||||
THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)
|
||||
THEN to_jsonb($3::text) END,
|
||||
'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)
|
||||
THEN to_jsonb($3::text) END)))
|
||||
ELSE $5::text::json
|
||||
END,
|
||||
COALESCE($8::timestamptz, now()), $9)
|
||||
sqlx::query_scalar!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = CASE
|
||||
WHEN NOT $10::bool
|
||||
OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0
|
||||
THEN EXCLUDED.value
|
||||
ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', to_jsonb(draft.value) -> 'path',
|
||||
'draft_path', to_jsonb(draft.value) -> 'draft_path')))
|
||||
END,
|
||||
created_at = EXCLUDED.created_at,
|
||||
base = EXCLUDED.base
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
RETURNING path, created_at"#,
|
||||
RETURNING created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
@@ -527,13 +380,9 @@ async fn update_draft(
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.created_at,
|
||||
base.as_deref(),
|
||||
moved_to.is_some(),
|
||||
url_path,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (r.created_at, Some(r.path)))
|
||||
} else {
|
||||
// Delete, same conflict rule in the WHERE clause. Returns NULL when
|
||||
// the row was too new (conflict) OR already absent (idempotent) —
|
||||
@@ -554,20 +403,16 @@ async fn update_draft(
|
||||
kind as UserDraftItemKind,
|
||||
req.last_sync,
|
||||
req.force,
|
||||
legacy_delete,
|
||||
req.legacy,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
// Named for the same reason an upsert is: the editor that discarded is still on
|
||||
// the path the item left, and reloading there would land on nothing.
|
||||
.map(|ts| (ts, moved_to.clone()))
|
||||
};
|
||||
|
||||
if let Some((ts, path)) = applied {
|
||||
if let Some(ts) = applied_at {
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: ts,
|
||||
path,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -583,7 +428,7 @@ async fn update_draft(
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
legacy_delete,
|
||||
req.legacy,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
@@ -592,307 +437,20 @@ async fn update_draft(
|
||||
Some(ts) => Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Conflict,
|
||||
current_timestamp: ts,
|
||||
path: None,
|
||||
})),
|
||||
// Delete + nothing-was-there ⇒ report success with server's NOW().
|
||||
None => {
|
||||
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
// A retry of a routed discard whose answer was lost lands here: the row is
|
||||
// gone but the editor is still on the path the item left, so it needs the
|
||||
// destination as much as the first attempt did. Unlike the arm above there is
|
||||
// no deleted row proving the caller ever held that draft, and an own discard
|
||||
// is not gated, so this names a path to someone who may have none of it: the
|
||||
// read gate, which is what keeps a path from being disclosed elsewhere in this
|
||||
// module. Without it, discarding at a guessed path reads `draft_move`.
|
||||
let disclosed = match moved_to {
|
||||
Some(dest) => {
|
||||
match require_can_read_path(&authed, &user_db, &w_id, kind, &dest).await {
|
||||
Ok(()) => Some(dest),
|
||||
Err(Error::NotFound(_))
|
||||
| Err(Error::NotAuthorized(_))
|
||||
| Err(Error::BadRequest(_)) => None,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: now,
|
||||
path: disclosed,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MoveDraftRequest {
|
||||
pub new_path: String,
|
||||
/// Also restate the draft's summary, so the same drawer that renames a
|
||||
/// deployed item can retitle a draft-only one.
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but
|
||||
/// its draft row, so moving it is a rewrite of that row's path plus both path
|
||||
/// keys inside its value — there is no deployed row, schedule or trigger to
|
||||
/// cascade to.
|
||||
///
|
||||
/// The owner's own open editor follows: its next save, still addressed to the old
|
||||
/// path, lands at the new one through the move record, and it is told where.
|
||||
///
|
||||
/// Scoped to the caller's own row on purpose: two users can each have a draft
|
||||
/// at the same never-deployed path, and those are two separate items.
|
||||
///
|
||||
/// A DEPLOYED item must move through its own deploy endpoint instead, which
|
||||
/// cascades everything that references the path and carries every draft along.
|
||||
async fn move_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
|
||||
Json(req): Json<MoveDraftRequest>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
let new_path = req.new_path.as_str();
|
||||
// Only the full-page editor kinds, which is exactly the set that has a typed
|
||||
// path to rewrite. Reading the movable set off the same mapping the rewrite
|
||||
// uses keeps them from drifting apart: a resource, a variable or a trigger
|
||||
// keeps its deploy target in `value.path` with no editor to stage a rename,
|
||||
// so moving one would leave the real target naming the old location and the
|
||||
// next deploy would recreate it where it came from.
|
||||
let (Some(typed_field), Some(mirror_field)) =
|
||||
(kind.typed_path_field(), kind.mirror_path_field())
|
||||
else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"moving a {kind:?} draft is not supported — only scripts, flows and apps"
|
||||
)));
|
||||
};
|
||||
// Validate before authorizing: `require_can_write_path` is not a format check
|
||||
// (an admin returns immediately, and a user returns early inside their own
|
||||
// namespace), so without this a malformed path is stored as-is, and an over-long
|
||||
// or NUL-bearing one reaches Postgres as a raw server error. The source is a URL
|
||||
// segment and arrives decoded, so it needs the same check as the destination.
|
||||
check_proper_path(path)?;
|
||||
check_proper_path(new_path)?;
|
||||
// A summary-only edit is a legitimate use of this endpoint: the drawer edits
|
||||
// both fields, and for a draft-only script the path it posts back is the row
|
||||
// path unchanged (`list_scripts` only reports `draft_path` when it differs).
|
||||
// Returning early on the path alone would drop the new summary silently.
|
||||
if new_path == path && req.summary.is_none() {
|
||||
return Ok("unchanged".to_string());
|
||||
}
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
|
||||
if new_path != path {
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?;
|
||||
}
|
||||
|
||||
if let Some(table) = kind.deployed_table() {
|
||||
// `table` is from the closed `deployed_table()` enum, never user input.
|
||||
// Archived and soft-deleted rows keep sitting at their path — a script
|
||||
// move archives its parent in place — so an existence check that counted
|
||||
// them would refuse a move away from, or into, a path nothing occupies.
|
||||
// `create_script_internal` resolves its own path clashes the same way.
|
||||
let archived_filter = if table == "script" {
|
||||
" AND NOT archived AND NOT deleted"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2{archived_filter} LIMIT 1"
|
||||
);
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let deployed_at_old = sqlx::query_scalar::<_, i32>(&query)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let deployed_at_new = sqlx::query_scalar::<_, i32>(&query)
|
||||
.bind(new_path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if deployed_at_old.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{path}' is deployed — move it from its editor so schedules and triggers follow"
|
||||
)));
|
||||
}
|
||||
if deployed_at_new.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// A NUL in the summary reaches Postgres as a raw byte and fails the statement with
|
||||
// an encoding error, so it is dropped here the way `strip_json_nul` drops one from a
|
||||
// draft value. `json` cannot store it either.
|
||||
let summary = req.summary.as_ref().map(|s| s.replace('\0', ""));
|
||||
|
||||
// A classic app and a raw app share the `app` table, so a draft of either kind
|
||||
// occupies the destination for both: deploying there deletes the caller's drafts
|
||||
// of both kinds, taking the item that lost the collision with it.
|
||||
let collision_typs: Vec<&str> = match kind {
|
||||
UserDraftItemKind::App | UserDraftItemKind::RawApp => vec![
|
||||
UserDraftItemKind::App.as_str(),
|
||||
UserDraftItemKind::RawApp.as_str(),
|
||||
],
|
||||
_ => vec![kind.as_str()],
|
||||
};
|
||||
|
||||
// One transaction with the move record, so a save addressed to the old path
|
||||
// never sees the row gone without knowing where it went.
|
||||
let mut tx = db.begin().await?;
|
||||
let moved = sqlx::query_scalar!(
|
||||
r#"UPDATE draft
|
||||
SET path = $3,
|
||||
-- Both path keys, not just the typed one: the editors mirror the
|
||||
-- typed path into the other while it differs from the row's path,
|
||||
-- and the loaders prefer the mirror — left naming the old location
|
||||
-- it un-does this move on the next save. `create_missing = false`
|
||||
-- on both, so a draft carrying only one keeps only one.
|
||||
value = to_json(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
CASE WHEN $7::text IS NULL THEN to_jsonb(value)
|
||||
ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))
|
||||
END,
|
||||
ARRAY[$5::text], to_jsonb($3::text), false
|
||||
),
|
||||
ARRAY[$8::text], to_jsonb($3::text), false
|
||||
)
|
||||
)
|
||||
WHERE workspace_id = $1
|
||||
AND path = $2
|
||||
AND typ = $4
|
||||
AND email = $6
|
||||
-- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded
|
||||
-- here so the statement can't 500; reported below instead. Unlike the
|
||||
-- passive carry, rewriting the value IS this operation, so skipping it
|
||||
-- silently would move the row and leave its typed path stale.
|
||||
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0
|
||||
-- Skipped on a summary-only edit, where the "target" row is this
|
||||
-- row and the guard would refuse the update against itself.
|
||||
AND ($2 = $3 OR NOT EXISTS (
|
||||
SELECT 1 FROM draft o
|
||||
WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])
|
||||
-- Of this kind only the caller's own row and the legacy one collide:
|
||||
-- teammates' drafts of one item share its path by design, but a deploy
|
||||
-- there wipes those two together, so a second would discard edits the
|
||||
-- caller never saw. The other app kind is a different item on the same
|
||||
-- deployed path, so it collides whoever owns it.
|
||||
AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)
|
||||
))
|
||||
RETURNING id"#,
|
||||
&w_id,
|
||||
path,
|
||||
new_path,
|
||||
kind as UserDraftItemKind,
|
||||
typed_field,
|
||||
&authed.email,
|
||||
summary,
|
||||
mirror_field,
|
||||
&collision_typs as &[&str],
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if moved.is_some() && new_path != path {
|
||||
windmill_common::user_drafts::record_draft_move(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&[kind],
|
||||
path,
|
||||
new_path,
|
||||
Some(&authed.email),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
if moved.is_none() {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT
|
||||
-- The guard's own predicate, ordered own row, then legacy, then another
|
||||
-- user's other-kind row: each needs a different remedy, and a bare
|
||||
-- LIMIT 1 would name an arbitrary one.
|
||||
(SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3
|
||||
AND typ::text = ANY($6::text[])
|
||||
AND (typ <> $2 OR email = $4 OR email IS NULL)
|
||||
ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END
|
||||
LIMIT 1) as "at_target",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
|
||||
AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
|
||||
AND typ::text = ANY($6::text[]) AND email IS NULL) as "at_target_legacy!",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
|
||||
AND typ = $2 AND email = $4
|
||||
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
|
||||
) as "poisoned!",
|
||||
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
|
||||
AND typ = $2 AND email IS NULL) as "legacy!" "#,
|
||||
&w_id,
|
||||
kind as UserDraftItemKind,
|
||||
new_path,
|
||||
&authed.email,
|
||||
path,
|
||||
&collision_typs as &[&str],
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
return Err(Error::BadRequest(if row.poisoned {
|
||||
// This endpoint also serves a summary-only edit, so name the operation
|
||||
// the caller actually asked for rather than always saying "moved".
|
||||
let attempted = if new_path == path { "updated" } else { "moved" };
|
||||
format!(
|
||||
"'{path}' contains a NUL character and predates the sanitizer, so it cannot be \
|
||||
{attempted}. Reopen it, re-save to rewrite it cleanly, then retry."
|
||||
)
|
||||
} else if row.legacy {
|
||||
// The home list synthesizes a draft-only row for the legacy draft with the
|
||||
// caller's own name on it, so this is reachable from the row menu. Only an
|
||||
// admin can claim or discard that row, and only from the drafts page.
|
||||
format!(
|
||||
"'{path}' is a legacy workspace draft with no owner, so it cannot be moved. \
|
||||
A workspace admin can claim or discard it on the Review & deploy drafts page."
|
||||
)
|
||||
} else if let Some(occupant) = row.at_target.filter(|_| new_path != path) {
|
||||
// Naming the kind matters for the app pair: a classic-app draft refusing a
|
||||
// raw-app move is invisible in the raw-app list the caller is looking at.
|
||||
let occupant = occupant.replace('_', " ");
|
||||
if row.at_target_own {
|
||||
format!("You already have a draft at '{new_path}' ({occupant})")
|
||||
} else if row.at_target_legacy {
|
||||
// An ownerless row the caller cannot clear themselves, so send them to
|
||||
// the one place it can be resolved rather than to "discard your draft".
|
||||
format!(
|
||||
"A legacy workspace draft with no owner is already at '{new_path}' \
|
||||
({occupant}). A workspace admin can claim or discard it on the Review & \
|
||||
deploy drafts page."
|
||||
)
|
||||
} else {
|
||||
// The other app kind, owned by someone else: one deployed path cannot hold
|
||||
// both, so this is the other item's path, not a teammate's copy of this one.
|
||||
format!(
|
||||
"'{new_path}' holds another user's {occupant} draft, and an app and a raw \
|
||||
app cannot share a path. Pick another path, or ask them to move or \
|
||||
discard theirs."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
format!("You have no draft at '{path}'")
|
||||
}));
|
||||
}
|
||||
|
||||
if new_path == path {
|
||||
return Ok(format!("updated draft {path}"));
|
||||
}
|
||||
Ok(format!("moved draft {path} to {new_path}"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MigrateLegacyDraftAction {
|
||||
@@ -946,12 +504,12 @@ async fn migrate_legacy_draft(
|
||||
r#"WITH legacy AS (
|
||||
DELETE FROM draft
|
||||
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
|
||||
RETURNING value, base
|
||||
RETURNING value
|
||||
)
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
SELECT $1, $4, $2, $3, value, now() FROM legacy
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
RETURNING 1 as "one!""#,
|
||||
&w_id,
|
||||
path,
|
||||
@@ -1209,8 +767,8 @@ async fn require_can_write_path(
|
||||
|
||||
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers:
|
||||
/// 1. admin → always.
|
||||
/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any
|
||||
/// folder in `authed.folders` (the precomputed read set, with groups + direct
|
||||
/// 2. Path-prefix match against own `u/{username}` or any folder in
|
||||
/// `authed.folders` (the precomputed read set, with groups + direct
|
||||
/// grants already factored in).
|
||||
/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level
|
||||
/// extra_perms grants that bypass folder/owner checks.
|
||||
@@ -1235,10 +793,6 @@ async fn require_can_read_path(
|
||||
if parts.len() >= 2 {
|
||||
match parts[0] {
|
||||
"u" if parts[1] == authed.username => return Ok(()),
|
||||
// As `require_can_write_path` and the `see_member` RLS policy read it: a
|
||||
// draft-only `g/` path has no row for the probe below to fall back on, so
|
||||
// without this a member cannot see a draft their group owns.
|
||||
"g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()),
|
||||
"f" => {
|
||||
let folder = parts[1];
|
||||
if authed.folders.iter().any(|(name, _, _)| name == folder) {
|
||||
|
||||
@@ -8723,7 +8723,12 @@ fn register_potential_assets_on_inline_execution(
|
||||
.as_ref()
|
||||
.and_then(|args| args.get("database"))
|
||||
.map(|v| v.get().trim_matches('"'))
|
||||
.and_then(|dt| dt.strip_prefix("datatable://"));
|
||||
.and_then(|dt| dt.strip_prefix("datatable://"))
|
||||
// `?role=` picks the connection, not the data table. Anything else after a `?` may be
|
||||
// part of a name stored before names were restricted, so it stays.
|
||||
.map(|dt| {
|
||||
windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name)
|
||||
});
|
||||
if let Some(datatable) = datatable {
|
||||
let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap();
|
||||
let (schema, content) = if let Some(captures) = re.captures(&preview.content) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user