mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-18 16:02:29 +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": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE descendants AS (\n SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id\n WHERE j.workspace_id = $2\n ) SELECT d.id AS \"id!\" FROM descendants d JOIN v2_job_queue q ON q.id = d.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"\n FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "success!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "result: Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+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
@@ -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"
|
||||
}
|
||||
+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 v2_job_queue SET worker = $1 WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e459c277c0bc27293d71972d12d26d32b476a43ed391c09d7678215195c4283a"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
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 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,181 +0,0 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::sync::Notify;
|
||||
use windmill_common::{flows::FlowValue, jobs::JobPayload};
|
||||
use windmill_test_utils::{
|
||||
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
|
||||
};
|
||||
|
||||
async fn model(Json(body): Json<Value>) -> ([(&'static str, &'static str); 1], String) {
|
||||
let finished = body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["role"] == "tool");
|
||||
let delta = if finished {
|
||||
json!({"role":"assistant", "content":"done"})
|
||||
} else {
|
||||
json!({"role":"assistant", "tool_calls": (0..3).map(|i| json!({
|
||||
"index":i, "id":format!("call_{i}"), "type":"function",
|
||||
"function":{"name":format!("tool_{i}"), "arguments":"{}"}
|
||||
})).collect::<Vec<_>>()})
|
||||
};
|
||||
let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]});
|
||||
let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if finished {"stop"} else {"tool_calls"}}]});
|
||||
(
|
||||
[("content-type", "text/event-stream")],
|
||||
format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_batch(db: Pool<Postgres>, parallel: bool, limited: bool) -> anyhow::Result<()> {
|
||||
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let base = format!("http://{}", listener.local_addr()?);
|
||||
let gate = Arc::new(Notify::new());
|
||||
let router = Router::new()
|
||||
.route("/v1/chat/completions", post(model))
|
||||
.route(
|
||||
"/first",
|
||||
get(|State(gate): State<Arc<Notify>>| async move {
|
||||
gate.notified().await;
|
||||
"ok"
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/second",
|
||||
get(|State(gate): State<Arc<Notify>>| async move {
|
||||
gate.notify_one();
|
||||
"ok"
|
||||
}),
|
||||
)
|
||||
.with_state(gate);
|
||||
let stub = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
let tools: Vec<Value> = (0..3).map(|i| {
|
||||
let wait = if parallel && !limited && i < 2 {
|
||||
format!("await fetch('{base}/{}');", if i == 0 {"first"} else {"second"})
|
||||
} else { String::new() };
|
||||
let finish = if i == 2 { "throw new Error('expected tool failure');".to_string() }
|
||||
else { format!("return {i};") };
|
||||
json!({"id":format!("t{i}"),"summary":format!("tool_{i}"),"value":{
|
||||
"type":"rawscript", "language":"bun", "input_transforms":{},
|
||||
"tag": if parallel { "bun" } else { "unserved-tool-tag" },
|
||||
"concurrent_limit": if limited { Some(1) } else { None },
|
||||
"custom_concurrency_key": if limited { Some("agent-tool-test") } else { None },
|
||||
"content":format!("export async function main() {{ {wait} await Bun.sleep({}); {finish} }}", if i == 0 {200} else {0})
|
||||
}})
|
||||
}).collect();
|
||||
let flow: FlowValue = serde_json::from_value(json!({"modules":[{"id":"agent","value":{
|
||||
"type":"aiagent", "tools":tools, "input_transforms":{
|
||||
"provider":{"type":"static","value":{"kind":"customai","model":"queue-test","resource":{"base_url":format!("{base}/v1")}}},
|
||||
"user_message":{"type":"static","value":"run the tools"},
|
||||
"max_iterations":{"type":"static","value":3}
|
||||
}
|
||||
}}]}))?;
|
||||
let id = RunJob::from(JobPayload::RawFlow {
|
||||
value: flow,
|
||||
path: Some("u/test/agent_queue".into()),
|
||||
restarted_from: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
let notifications = listen_for_completed_jobs(&db).await;
|
||||
let wait = async {
|
||||
if parallel {
|
||||
in_test_worker(&db, notifications.find(&id), server.addr.port()).await;
|
||||
} else {
|
||||
notifications.find(&id).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(45),
|
||||
in_test_worker(&db, wait, server.addr.port()),
|
||||
)
|
||||
.await?;
|
||||
let result = completed_job(id, &db).await;
|
||||
assert!(result.success, "{:?}", result.result);
|
||||
let result = result.json_result().expect("agent result");
|
||||
let messages: Vec<_> = result["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "tool")
|
||||
.collect();
|
||||
assert_eq!(messages.len(), 3);
|
||||
for (index, message) in messages.iter().enumerate() {
|
||||
assert_eq!(message["tool_call_id"], format!("call_{index}"));
|
||||
}
|
||||
assert_eq!(messages[0]["content"], "0");
|
||||
assert_eq!(messages[1]["content"], "1");
|
||||
assert!(messages[2]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("expected tool failure"));
|
||||
let (parent_id, parent_worker): (uuid::Uuid, String) = sqlx::query_as(
|
||||
"SELECT j.id, c.worker FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1"
|
||||
).bind(id).fetch_one(&db).await?;
|
||||
let children: Vec<(String, String, bool)> = sqlx::query_as(
|
||||
"SELECT j.runnable_path, c.worker, c.status = 'success' FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1 ORDER BY j.runnable_path"
|
||||
).bind(parent_id).fetch_all(&db).await?;
|
||||
assert_eq!(children.len(), 3);
|
||||
assert_eq!(
|
||||
children[0].1, parent_worker,
|
||||
"first tool must stay on the parent worker"
|
||||
);
|
||||
assert_eq!(
|
||||
children.iter().map(|c| c.2).collect::<Vec<_>>(),
|
||||
vec![true, true, false]
|
||||
);
|
||||
if limited {
|
||||
let overlapping: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM v2_job j1 JOIN v2_job_completed c1 ON c1.id = j1.id
|
||||
JOIN v2_job j2 ON j2.parent_job = j1.parent_job AND j2.id > j1.id
|
||||
JOIN v2_job_completed c2 ON c2.id = j2.id
|
||||
WHERE j1.parent_job = $1 AND c1.started_at < c2.completed_at AND c2.started_at < c1.completed_at"
|
||||
).bind(parent_id).fetch_one(&db).await?;
|
||||
assert_eq!(
|
||||
overlapping, 0,
|
||||
"the reserved first job must count toward the shared limit"
|
||||
);
|
||||
} else if parallel {
|
||||
assert_ne!(
|
||||
children[1].1, parent_worker,
|
||||
"second tool must unblock the first from another worker"
|
||||
);
|
||||
} else {
|
||||
assert!(children.iter().all(|child| child.1 == parent_worker));
|
||||
}
|
||||
stub.abort();
|
||||
server.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial_test::serial]
|
||||
async fn parent_drains_tools_without_another_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
run_batch(db, false, false).await
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial_test::serial]
|
||||
async fn parent_keeps_first_tool_while_another_worker_runs_siblings(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
run_batch(db, true, false).await
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial_test::serial]
|
||||
async fn reserved_tool_obeys_shared_concurrency_limit(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
run_batch(db, true, true).await
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,121 +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"
|
||||
);
|
||||
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?,
|
||||
"kept"
|
||||
);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1409,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!(
|
||||
@@ -1420,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
|
||||
|
||||
@@ -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
|
||||
@@ -33659,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]
|
||||
@@ -33719,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:
|
||||
@@ -33742,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
|
||||
@@ -34801,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
|
||||
@@ -35782,6 +36208,7 @@ components:
|
||||
enum:
|
||||
- postgresql
|
||||
- instance
|
||||
- external_instance
|
||||
resource_path:
|
||||
type: string
|
||||
required:
|
||||
@@ -35865,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
|
||||
@@ -35878,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -560,7 +560,7 @@ pub async fn run_server(
|
||||
if server_mode || mcp_mode {
|
||||
use mcp::{
|
||||
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
||||
extract_workspace_from_token, reject_token_query_param,
|
||||
extract_workspace_from_token,
|
||||
};
|
||||
let (mcp_router, mcp_cancellation_token) = setup_mcp_server(
|
||||
db.clone(),
|
||||
@@ -573,17 +573,15 @@ pub async fn run_server(
|
||||
let workspaced_mcp_router = mcp_router
|
||||
.clone()
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(reject_token_query_param))
|
||||
.layer(axum::middleware::from_fn(add_www_authenticate_header))
|
||||
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
|
||||
// Gateway MCP router — resolves workspace from token
|
||||
let gateway_mcp_router = mcp_router
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(extract_workspace_from_token))
|
||||
.layer(axum::middleware::from_fn(reject_token_query_param))
|
||||
.layer(axum::middleware::from_fn(
|
||||
add_www_authenticate_header_gateway,
|
||||
));
|
||||
))
|
||||
.layer(axum::middleware::from_fn(extract_workspace_from_token));
|
||||
(
|
||||
workspaced_mcp_router,
|
||||
gateway_mcp_router,
|
||||
|
||||
@@ -43,14 +43,11 @@ use axum::{
|
||||
extract::{Extension, Path},
|
||||
http::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use windmill_common::{
|
||||
auth::hash_token, db::GatewayWorkspaceId, error::JsonResult,
|
||||
global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM,
|
||||
};
|
||||
use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult};
|
||||
|
||||
// McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type)
|
||||
|
||||
@@ -449,29 +446,6 @@ pub async fn add_www_authenticate_header(
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware refusing a credential carried in the MCP URL once the instance sets
|
||||
/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither
|
||||
/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer,
|
||||
/// whose header is what sends the client into the OAuth flow instead. Refused rather than
|
||||
/// ignored: the URL leaked the token whether or not the request used it.
|
||||
pub async fn reject_token_query_param(request: Request<axum::body::Body>, next: Next) -> Response {
|
||||
let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed)
|
||||
&& request
|
||||
.uri()
|
||||
.query()
|
||||
.is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token"));
|
||||
if carries_token {
|
||||
return (
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"This instance does not accept a token in the MCP URL. Remove the token query \
|
||||
parameter and let your client sign in through OAuth, or send the token in an \
|
||||
Authorization header.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Extract the bearer token from either the `Authorization` header or the
|
||||
/// `?token=` query parameter (MCP clients commonly pass it in the URL).
|
||||
fn extract_gateway_token(request: &Request<axum::body::Body>) -> Option<String> {
|
||||
|
||||
@@ -12,5 +12,5 @@ pub mod oauth_server;
|
||||
pub use core::{
|
||||
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
||||
extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service,
|
||||
reject_token_query_param, setup_mcp_server,
|
||||
setup_mcp_server,
|
||||
};
|
||||
|
||||
@@ -76,6 +76,7 @@ bitflags.workspace = true
|
||||
once_cell.workspace = true
|
||||
phf.workspace = true
|
||||
tokio-postgres.workspace = true
|
||||
postgres-protocol.workspace = true
|
||||
postgres-native-tls.workspace = true
|
||||
native-tls.workspace = true
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`). System-token labels are load-bearing —
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing —
|
||||
/// session cleanup, super_admin propagation, expiry notifications and username overrides
|
||||
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
|
||||
/// a user token.
|
||||
@@ -47,17 +47,10 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with("impersonation:")
|
||||
&& !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far ahead of a user token's expiration its owner is warned (`check_expiring_tokens` in
|
||||
/// the monitor). A token whose whole lifetime fits in this window gets no warning at all: it
|
||||
/// would arrive minutes after creation, about a lifetime its creator just picked. Its
|
||||
/// "expired and deleted" notice still goes out.
|
||||
pub const TOKEN_EXPIRY_WARNING_DAYS: i32 = 7;
|
||||
|
||||
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
|
||||
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
||||
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
|
||||
@@ -66,13 +59,6 @@ pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
|
||||
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
|
||||
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
|
||||
|
||||
/// Label prefix, followed by the username, of the token the CLI authorization page mints for
|
||||
/// `wmill` logins. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out:
|
||||
/// the CLI signs in again on its own once that token expires, so an expiry email for it asks
|
||||
/// the user to do nothing. Not in [`is_server_minted_label`], since the page mints it through
|
||||
/// `/users/tokens/create`.
|
||||
pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:";
|
||||
|
||||
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
|
||||
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
|
||||
@@ -489,16 +475,14 @@ async fn fetch_authed_from_permissioned_as_inner(
|
||||
// principal — a username freed and reassigned while its previous holder keeps a privileged
|
||||
// account — would mix one account's role with another's instance privileges.
|
||||
let member = match permissioned_as.split_once('/') {
|
||||
Some(("u", name)) => {
|
||||
sqlx::query!(
|
||||
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
|
||||
Some(("u", name)) => sqlx::query!(
|
||||
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
|
||||
workspace_id = $2 AND disabled = false",
|
||||
name,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
}
|
||||
name,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?,
|
||||
_ => None,
|
||||
};
|
||||
let resolved_email;
|
||||
@@ -996,7 +980,6 @@ mod tests {
|
||||
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
|
||||
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
|
||||
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
|
||||
assert!(!is_user_token(Some("cli-login:admin")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// What every roles path answers without the Enterprise Edition.
|
||||
/// What every roles path answers without the Enterprise Edition. The frontend matches this exact
|
||||
/// sentence (`datatableUsableRoles.ts`) to read the refusal as "not under roles": reword both.
|
||||
pub fn datatable_roles_unavailable() -> Error {
|
||||
Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! The external Postgres cluster behind `external_instance` data tables and Ducklake catalogs.
|
||||
//!
|
||||
//! Windmill administers that cluster itself, logged in as the user in
|
||||
//! [`EXTERNAL_INSTANCE_PG_SETTING`]. It creates `custom_instance_user` and
|
||||
//! `custom_instance_replication_user` there, with passwords it generates and keeps in
|
||||
//! [`EXTERNAL_INSTANCE_PG_STATE_SETTING`]. They share their names with the roles on Windmill's own
|
||||
//! cluster, but they are different roles with different passwords.
|
||||
//!
|
||||
//! The cluster may hold data Windmill did not create. Two Windmill instances sharing one is not
|
||||
//! supported: each would keep resetting the passwords the other depends on.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
global_settings::{EXTERNAL_INSTANCE_PG_SETTING, EXTERNAL_INSTANCE_PG_STATE_SETTING},
|
||||
instance_config::{CustomInstanceDb, ExternalInstancePg},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// What Windmill keeps about the external cluster. Server-managed and hidden: never part of the
|
||||
/// instance config, never readable by an agent worker. No `Debug`: it carries live passwords.
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ExternalInstancePgState {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user_pwd: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub replication_pwd: Option<String>,
|
||||
/// The databases Windmill created on the cluster. It only ever drops one of these.
|
||||
#[serde(default)]
|
||||
pub databases: BTreeMap<String, CustomInstanceDb>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_setup: Option<ExternalInstancePgSetupReport>,
|
||||
/// The cluster ([`external_instance_pg_address`]) the last successful setup converged. Databases
|
||||
/// are only created on a cluster setup succeeded on: the passwords above exist as soon as setup
|
||||
/// first runs, whether or not the cluster accepted them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub set_up_for: Option<String>,
|
||||
}
|
||||
|
||||
/// What identifies the cluster a configuration points at. Other fields (admin login, sslmode) can
|
||||
/// change without it becoming another cluster.
|
||||
pub fn external_instance_pg_address(config: &ExternalInstancePg) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
config.host.trim().to_lowercase(),
|
||||
config.port.unwrap_or(5432)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ExternalInstancePgSetupReport {
|
||||
/// No step failed. Warnings leave it true.
|
||||
pub success: bool,
|
||||
pub finished_at: chrono::DateTime<chrono::Utc>,
|
||||
pub steps: Vec<ExternalInstancePgSetupStep>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ExternalInstancePgSetupStep {
|
||||
pub name: String,
|
||||
pub status: SetupStepStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SetupStepStatus {
|
||||
Ok,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// The status the settings page shows without running anything.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ExternalInstancePgStatus {
|
||||
pub configured: bool,
|
||||
pub database_count: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_setup: Option<ExternalInstancePgSetupReport>,
|
||||
}
|
||||
|
||||
/// Authorization: returns the cluster's admin password and checks nothing. Callers MUST be
|
||||
/// superadmin or an internal server path.
|
||||
pub(crate) async fn read_external_instance_pg_config<'c>(
|
||||
executor: impl sqlx::PgExecutor<'c>,
|
||||
) -> Result<Option<ExternalInstancePg>> {
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
EXTERNAL_INSTANCE_PG_SETTING
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
value
|
||||
.map(|v| {
|
||||
serde_json::from_value(v).map_err(|e| {
|
||||
Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_SETTING}: {e}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Authorization: returns the passwords Windmill generated on the cluster and checks nothing.
|
||||
/// Callers MUST be superadmin or an internal server path.
|
||||
pub(crate) async fn read_external_instance_pg_state<'c>(
|
||||
executor: impl sqlx::PgExecutor<'c>,
|
||||
) -> Result<ExternalInstancePgState> {
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
EXTERNAL_INSTANCE_PG_STATE_SETTING
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
match value {
|
||||
None => Ok(ExternalInstancePgState::default()),
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| {
|
||||
Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_STATE_SETTING}: {e}"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn external_instance_pg_status(db: &DB) -> Result<ExternalInstancePgStatus> {
|
||||
let configured = read_external_instance_pg_config(db).await?.is_some();
|
||||
let state = read_external_instance_pg_state(db).await?;
|
||||
Ok(ExternalInstancePgStatus {
|
||||
configured,
|
||||
database_count: state.databases.len(),
|
||||
last_setup: state.last_setup,
|
||||
})
|
||||
}
|
||||
|
||||
/// The databases Windmill created on the external cluster, without the passwords kept beside them.
|
||||
///
|
||||
/// Authorization: names every database across all workspaces, and the workspace each fork copy is
|
||||
/// reserved for, and checks nothing. Callers MUST be superadmin or an internal authorization or
|
||||
/// lifecycle path that does not return the names to a workspace caller.
|
||||
pub async fn external_instance_databases(db: &DB) -> Result<BTreeMap<String, CustomInstanceDb>> {
|
||||
Ok(read_external_instance_pg_state(db).await?.databases)
|
||||
}
|
||||
|
||||
/// The workspaces whose data tables name each database on the external cluster.
|
||||
///
|
||||
/// Authorization: reads every workspace's settings and checks nothing. Callers MUST be superadmin
|
||||
/// or an internal lifecycle path.
|
||||
pub async fn external_instance_database_usages<'c>(
|
||||
db: impl sqlx::PgExecutor<'c>,
|
||||
) -> Result<BTreeMap<String, BTreeSet<String>>> {
|
||||
let rows = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT ws.workspace_id, entry->'database'->>'resource_path'
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
THEN ws.datatable->'datatables'
|
||||
ELSE '{}'::jsonb END
|
||||
) AS dt(k, entry)
|
||||
WHERE entry->'database'->>'resource_type' = 'external_instance'
|
||||
AND entry->'database'->>'resource_path' IS NOT NULL",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
let mut usages: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for (workspace_id, dbname) in rows {
|
||||
usages.entry(dbname).or_default().insert(workspace_id);
|
||||
}
|
||||
Ok(usages)
|
||||
}
|
||||
|
||||
/// Refuse to unset the cluster while Windmill still has databases on it, or a workspace still
|
||||
/// points at one: every data table there would stop resolving. Allowed on every edition, so a
|
||||
/// downgraded instance can still clear a setting it no longer uses.
|
||||
pub async fn ensure_external_instance_pg_removable(conn: &mut sqlx::PgConnection) -> Result<()> {
|
||||
let state = read_external_instance_pg_state(&mut *conn).await?;
|
||||
let usages = external_instance_database_usages(&mut *conn).await?;
|
||||
if state.databases.is_empty() && usages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let names = state
|
||||
.databases
|
||||
.keys()
|
||||
.chain(usages.keys())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(Error::BadRequest(format!(
|
||||
"The external instance cluster still holds databases in use ({names}). Drop them and \
|
||||
repoint the data tables using them before removing {EXTERNAL_INSTANCE_PG_SETTING}."
|
||||
)))
|
||||
}
|
||||
|
||||
/// Refuse a workspace setting that newly names an `external_instance` database on an edition
|
||||
/// without them.
|
||||
pub fn ensure_external_instance_available() -> Result<()> {
|
||||
crate::external_instance_pg_oss::ensure_external_instance_available()
|
||||
}
|
||||
|
||||
/// The connection an `external_instance` database resolves to: `custom_instance_user`, or the
|
||||
/// replication user, on the external cluster.
|
||||
///
|
||||
/// Authorization: returns live credentials and checks nothing. Callers MUST have authorized access
|
||||
/// to the data table that names `dbname`.
|
||||
pub async fn external_instance_connection_unchecked(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
replication: bool,
|
||||
) -> Result<crate::PgDatabase> {
|
||||
crate::external_instance_pg_oss::external_instance_connection_unchecked(db, dbname, replication)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create `dbname` on the external cluster and register it. Refuses a name already taken there,
|
||||
/// whoever took it.
|
||||
///
|
||||
/// Authorization: checks nothing. Callers MUST be superadmin, or be cloning a data table they may
|
||||
/// fork into a `wm_fork_` database.
|
||||
pub async fn create_external_instance_database_unchecked(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
tag: &str,
|
||||
for_workspace: Option<&str>,
|
||||
) -> Result<()> {
|
||||
crate::external_instance_pg_oss::create_external_instance_database_unchecked(
|
||||
db,
|
||||
dbname,
|
||||
tag,
|
||||
for_workspace,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Drop `dbname` from the external cluster: only a database Windmill registered creating, and still
|
||||
/// carries the mark it set there. Refused while anything uses it
|
||||
/// ([`crate::workspaces::managed_database_uses`]), except the `exempt` data table entry: the fork
|
||||
/// copy being cleaned up.
|
||||
///
|
||||
/// Authorization: checks nothing. Callers MUST be superadmin, or be deleting the fork that owns
|
||||
/// this `wm_fork_` database.
|
||||
pub async fn drop_external_instance_database_unchecked(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
exempt: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
crate::external_instance_pg_oss::drop_external_instance_database_unchecked(db, dbname, exempt)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Serializes everything that changes which databases exist on the external cluster, or which data
|
||||
/// tables name them: setup, creates, drops, and data table saves. Held until `tx` ends.
|
||||
pub async fn lock_external_instance_pg_state(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
|
||||
.bind(EXTERNAL_INSTANCE_PG_STATE_SETTING)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse a data table naming `dbname` unless Windmill created it on the external cluster. Takes
|
||||
/// the lock drops take, so none can remove the database before `tx`, which saves the data table,
|
||||
/// commits.
|
||||
///
|
||||
/// Authorization: its refusal says whether Windmill created a database of that name, which is
|
||||
/// instance-wide knowledge. Callers MUST have authorized the caller as superadmin first.
|
||||
pub async fn ensure_external_instance_database_registered(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
dbname: &str,
|
||||
) -> Result<()> {
|
||||
lock_external_instance_pg_state(tx).await?;
|
||||
if read_external_instance_pg_state(&mut **tx)
|
||||
.await?
|
||||
.databases
|
||||
.contains_key(dbname)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"Windmill did not create a database named '{dbname}' on the external instance cluster. \
|
||||
Create it from the instance settings first."
|
||||
)))
|
||||
}
|
||||
|
||||
/// Write [`EXTERNAL_INSTANCE_PG_SETTING`]: `None`, null or an empty string unsets it. Every writer
|
||||
/// of global settings goes through this for that key — the per-key and bulk endpoints as well as
|
||||
/// the declarative sync — instead of writing the row itself.
|
||||
///
|
||||
/// The checks and the write share one transaction holding [`lock_external_instance_pg_state`]. A
|
||||
/// check taken outside it could pass while a database create still reads the old cluster, which
|
||||
/// would then register a database there after the setting names another one.
|
||||
///
|
||||
/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config
|
||||
/// sync, which applies what the operator deployed.
|
||||
pub async fn write_external_instance_pg_setting(
|
||||
db: &DB,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let value = match value {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => None,
|
||||
Some(value) => Some(value),
|
||||
};
|
||||
let mut tx = db.begin().await?;
|
||||
lock_external_instance_pg_state(&mut tx).await?;
|
||||
// Every check runs on this transaction's own connection: it holds the advisory lock, and
|
||||
// taking a second connection from the pool while other writers queue on that lock is how a
|
||||
// small pool deadlocks.
|
||||
match value {
|
||||
None => {
|
||||
ensure_external_instance_pg_removable(&mut tx).await?;
|
||||
sqlx::query("DELETE FROM global_settings WHERE name = $1")
|
||||
.bind(EXTERNAL_INSTANCE_PG_SETTING)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(value) => {
|
||||
crate::external_instance_pg_oss::validate_external_instance_pg_setting(value)?;
|
||||
ensure_external_instance_pg_not_repointed(&mut tx, value).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now()",
|
||||
)
|
||||
.bind(EXTERNAL_INSTANCE_PG_SETTING)
|
||||
.bind(value)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
tracing::info!(
|
||||
"{} global setting {EXTERNAL_INSTANCE_PG_SETTING}",
|
||||
if value.is_some() { "Set" } else { "Unset" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`write_external_instance_pg_setting`] for a settings diff: writes the key if the diff touches
|
||||
/// it, and takes it out of the diff so the generic apply does not write it again.
|
||||
///
|
||||
/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config
|
||||
/// sync, which applies what the operator deployed.
|
||||
pub async fn write_external_instance_pg_from_diff(
|
||||
db: &DB,
|
||||
diff: &mut crate::instance_config::SettingsDiff,
|
||||
) -> Result<()> {
|
||||
if let Some(value) = diff.upserts.remove(EXTERNAL_INSTANCE_PG_SETTING) {
|
||||
write_external_instance_pg_setting(db, Some(&value)).await?;
|
||||
}
|
||||
if let Some(i) = diff
|
||||
.deletes
|
||||
.iter()
|
||||
.position(|k| k == EXTERNAL_INSTANCE_PG_SETTING)
|
||||
{
|
||||
diff.deletes.remove(i);
|
||||
write_external_instance_pg_setting(db, None).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse pointing the setting at another host or port while databases live on the current one.
|
||||
/// Data tables name databases, not clusters, so they would silently resolve to whatever the new
|
||||
/// cluster holds under the same names. Other fields (admin login, sslmode) may change freely.
|
||||
async fn ensure_external_instance_pg_not_repointed(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let Some(current) = read_external_instance_pg_config(&mut *conn).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(desired) = serde_json::from_value::<ExternalInstancePg>(value.clone()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if external_instance_pg_address(¤t) == external_instance_pg_address(&desired) {
|
||||
return Ok(());
|
||||
}
|
||||
let state = read_external_instance_pg_state(&mut *conn).await?;
|
||||
let usages = external_instance_database_usages(&mut *conn).await?;
|
||||
if state.databases.is_empty() && usages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"The external instance cluster at {}:{} still holds databases in use. Drop them and repoint \
|
||||
what uses them before pointing {EXTERNAL_INSTANCE_PG_SETTING} at another cluster.",
|
||||
current.host.trim(),
|
||||
current.port.unwrap_or(5432)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Converge the external cluster on the configured login: check what it can do, create or update
|
||||
/// Windmill's two roles with the stored passwords, and report anything that would get in the way.
|
||||
/// With `rotate_passwords`, generate new passwords first. Safe to run again; running it again is
|
||||
/// how a failed rotation is repaired.
|
||||
///
|
||||
/// Authorization: administers the external cluster with its admin credentials and checks nothing.
|
||||
/// Callers MUST be superadmin.
|
||||
pub async fn setup_external_instance_pg_unchecked(
|
||||
db: &DB,
|
||||
rotate_passwords: bool,
|
||||
) -> Result<ExternalInstancePgSetupReport> {
|
||||
crate::external_instance_pg_oss::setup_external_instance_pg_unchecked(db, rotate_passwords)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Where the external instance cluster comes from: the enterprise implementation, or a refusal.
|
||||
//! `private` alone is not that edition: community builds carry it.
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub fn external_instance_pg_unavailable() -> Error {
|
||||
Error::BadRequest(
|
||||
"External instance databases are a Windmill Enterprise Edition feature".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) use crate::external_instance_pg_ee::{
|
||||
create_external_instance_database_unchecked, drop_external_instance_database_unchecked,
|
||||
external_instance_connection_unchecked, setup_external_instance_pg_unchecked,
|
||||
validate_external_instance_pg_setting,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub(crate) fn ensure_external_instance_available() -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub(crate) use ce::*;
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod ce {
|
||||
use super::external_instance_pg_unavailable as unavailable;
|
||||
use crate::{
|
||||
error::Result, external_instance_pg::ExternalInstancePgSetupReport, PgDatabase, DB,
|
||||
};
|
||||
|
||||
pub(crate) fn validate_external_instance_pg_setting(_value: &serde_json::Value) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_external_instance_available() -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn setup_external_instance_pg_unchecked(
|
||||
_db: &DB,
|
||||
_rotate_passwords: bool,
|
||||
) -> Result<ExternalInstancePgSetupReport> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn external_instance_connection_unchecked(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
_replication: bool,
|
||||
) -> Result<PgDatabase> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn create_external_instance_database_unchecked(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
_tag: &str,
|
||||
_for_workspace: Option<&str>,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
|
||||
pub(crate) async fn drop_external_instance_database_unchecked(
|
||||
_db: &DB,
|
||||
_dbname: &str,
|
||||
_exempt: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ pub const SAML_METADATA_SETTING: &str = "saml_metadata";
|
||||
pub const SMTP_SETTING: &str = "smtp_settings";
|
||||
pub const TEAMS_SETTING: &str = "teams";
|
||||
pub const INDEXER_SETTING: &str = "indexer_settings";
|
||||
pub const EXTERNAL_INSTANCE_PG_SETTING: &str = "external_instance_pg";
|
||||
pub const EXTERNAL_INSTANCE_PG_STATE_SETTING: &str = "external_instance_pg_state";
|
||||
pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result";
|
||||
|
||||
pub const UNIQUE_ID_SETTING: &str = "uid";
|
||||
@@ -102,49 +104,6 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
|
||||
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
|
||||
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
|
||||
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
|
||||
/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way
|
||||
/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an
|
||||
/// instance that cares sends MCP clients through the OAuth flow instead.
|
||||
pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param";
|
||||
/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or
|
||||
/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no
|
||||
/// expiration at all, is shortened to it rather than refused. On those routes only: server-side
|
||||
/// mints (webhook tokens, app embed tokens, sessions) choose a lifetime the caller never picks
|
||||
/// and go straight to `create_token_internal`. Read and validated by
|
||||
/// [`parse_max_token_expiration_days`].
|
||||
pub const MAX_TOKEN_EXPIRATION_DAYS_SETTING: &str = "max_token_expiration_days";
|
||||
/// Largest `max_token_expiration_days` read as a ceiling, about 2,700 years. The token form
|
||||
/// applies the same bound (`frontend/src/lib/tokenExpiration.ts`) so that it and the server
|
||||
/// agree on whether a ceiling exists.
|
||||
pub const MAX_TOKEN_EXPIRATION_DAYS_BOUND: i64 = 1_000_000;
|
||||
|
||||
/// Reads a stored `max_token_expiration_days`: `Ok(None)` when unset or cleared (null or an
|
||||
/// empty string), the ceiling for a whole number of days within
|
||||
/// `1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND` stored as an integer, an integral float or a string of
|
||||
/// digits, and an error for anything else.
|
||||
///
|
||||
/// The settings API and config sync both reject the error at write time: the token routes can
|
||||
/// only read an unparseable value as no ceiling, so accepting a typo would silently turn the
|
||||
/// policy off. `parseMaxTokenExpirationDays` in the frontend must accept exactly the same values.
|
||||
pub fn parse_max_token_expiration_days(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<Option<i64>, String> {
|
||||
let days = match value {
|
||||
None | Some(serde_json::Value::Null) => return Ok(None),
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => return Ok(None),
|
||||
Some(serde_json::Value::Number(n)) => n
|
||||
.as_i64()
|
||||
.or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
|
||||
Some(serde_json::Value::String(s)) => s.trim().parse::<i64>().ok(),
|
||||
Some(_) => None,
|
||||
};
|
||||
match days {
|
||||
Some(days) if (1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND).contains(&days) => Ok(Some(days)),
|
||||
_ => Err(format!(
|
||||
"must be a whole number of days from 1 to {MAX_TOKEN_EXPIRATION_DAYS_BOUND}, or empty for no limit"
|
||||
)),
|
||||
}
|
||||
}
|
||||
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
|
||||
/// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or
|
||||
/// empty leaves instance-group membership entirely to SCIM.
|
||||
@@ -398,6 +357,9 @@ pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[
|
||||
// resolve datatable connections through the dedicated datatable endpoints, never these.
|
||||
"custom_instance_pg_databases",
|
||||
"custom_instance_replication_pwd",
|
||||
// The external cluster's admin login, and the passwords Windmill generated on it.
|
||||
EXTERNAL_INSTANCE_PG_SETTING,
|
||||
EXTERNAL_INSTANCE_PG_STATE_SETTING,
|
||||
];
|
||||
|
||||
/// Whether an agent worker may read the given global setting over HTTP.
|
||||
@@ -411,7 +373,6 @@ use std::sync::atomic::AtomicBool;
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
|
||||
/// Origins HTTP routes allow cross-origin when they configure none of their
|
||||
/// own. Empty means unset, which keeps the historical `*`.
|
||||
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
|
||||
@@ -855,55 +816,6 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// `frontend/src/lib/tokenExpiration.test.ts` holds the same table for the token form's
|
||||
// parser; the two must stay in step.
|
||||
#[test]
|
||||
fn max_token_expiration_days_accepts_only_whole_days_within_the_bound() {
|
||||
use serde_json::json;
|
||||
for (stored, days) in [
|
||||
(json!(7), 7),
|
||||
(json!(7.0), 7),
|
||||
(json!("7"), 7),
|
||||
(json!(" 30 "), 30),
|
||||
(json!("+7"), 7),
|
||||
(
|
||||
json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND),
|
||||
MAX_TOKEN_EXPIRATION_DAYS_BOUND,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_max_token_expiration_days(Some(&stored)),
|
||||
Ok(Some(days)),
|
||||
"{stored}"
|
||||
);
|
||||
}
|
||||
for cleared in [json!(null), json!(""), json!(" ")] {
|
||||
assert_eq!(
|
||||
parse_max_token_expiration_days(Some(&cleared)),
|
||||
Ok(None),
|
||||
"{cleared}"
|
||||
);
|
||||
}
|
||||
assert_eq!(parse_max_token_expiration_days(None), Ok(None));
|
||||
for bad in [
|
||||
json!(7.5),
|
||||
json!(0),
|
||||
json!(-3),
|
||||
json!("7.0"),
|
||||
json!("1e1"),
|
||||
json!("0x7"),
|
||||
json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND + 1),
|
||||
json!("99999999999999999999"),
|
||||
json!(true),
|
||||
json!([7]),
|
||||
] {
|
||||
assert!(
|
||||
parse_max_token_expiration_days(Some(&bad)).is_err(),
|
||||
"{bad} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_base_url_errors_never_echo_credentials() {
|
||||
// These strings reach sync-config output and operator logs, so no branch may
|
||||
|
||||
@@ -350,6 +350,8 @@ pub struct GlobalSettings {
|
||||
pub ducklake_settings: Option<DucklakeSettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom_instance_pg_databases: Option<CustomInstancePgDatabases>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub external_instance_pg: Option<ExternalInstancePg>,
|
||||
|
||||
// Opaque settings (EE-private structs or no clear schema)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -809,6 +811,9 @@ pub struct CustomInstanceDb {
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
/// The workspace a member created this fork copy for. Absent when a superadmin created it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Setup log entries for a custom instance database.
|
||||
@@ -833,6 +838,36 @@ pub struct CustomInstanceDbLogs {
|
||||
pub replication_user_error: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External instance PG cluster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The external Postgres cluster Windmill manages for `external_instance` data tables and Ducklake
|
||||
/// catalogs. `user` logs in as the cluster's administrator: it needs `CREATEDB` and `CREATEROLE`.
|
||||
/// `dbname` is only where that login connects to run cluster-wide statements.
|
||||
///
|
||||
/// Every field defaults rather than being required: this deserializes as part of the whole
|
||||
/// instance config, and one malformed row must not make every other setting unreadable. The
|
||||
/// write path and every use reject an incomplete value instead.
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
|
||||
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
|
||||
pub struct ExternalInstancePg {
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<StringOrSecretRef>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dbname: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sslmode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root_certificate_pem: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autoscaling (worker config)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -969,6 +1004,7 @@ pub const PROTECTED_SETTINGS: &[&str] = &[
|
||||
"ducklake_settings",
|
||||
"custom_instance_pg_databases",
|
||||
"custom_instance_replication_pwd",
|
||||
"external_instance_pg_state",
|
||||
"uid",
|
||||
"rsa_keys",
|
||||
"jwt_secret",
|
||||
@@ -994,6 +1030,8 @@ pub const HIDDEN_SETTINGS: &[&str] = &[
|
||||
// Server-only (written by setup/refresh via direct SQL), never operator-authored —
|
||||
// hidden so the config machinery can't read, rewrite, or drop it.
|
||||
"custom_instance_replication_pwd",
|
||||
// Same for the passwords and database registry Windmill keeps for the external cluster.
|
||||
"external_instance_pg_state",
|
||||
];
|
||||
|
||||
/// Top-level settings whose entire value is sensitive and must be fully redacted in logs.
|
||||
@@ -1005,6 +1043,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[
|
||||
"license_key",
|
||||
"ducklake_user_pg_pwd",
|
||||
"custom_instance_replication_pwd",
|
||||
"external_instance_pg_state",
|
||||
"pip_index_url",
|
||||
"pip_extra_index_url",
|
||||
"npm_config_registry",
|
||||
@@ -1030,6 +1069,7 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[
|
||||
&["secret_key", "serviceAccountKey"],
|
||||
),
|
||||
("custom_instance_pg_databases", &["user_pwd"]),
|
||||
("external_instance_pg", &["password"]),
|
||||
];
|
||||
|
||||
fn redact_json_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
@@ -1358,11 +1398,8 @@ pub async fn sync_global_settings_declarative(
|
||||
crate::global_settings::parse_allowed_origins_setting(desired.get(origins_key))
|
||||
.map_err(|e| anyhow::anyhow!("{origins_key}: {e}"))?;
|
||||
|
||||
let max_expiration_key = crate::global_settings::MAX_TOKEN_EXPIRATION_DAYS_SETTING;
|
||||
crate::global_settings::parse_max_token_expiration_days(desired.get(max_expiration_key))
|
||||
.map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?;
|
||||
|
||||
let diff = diff_global_settings(current, desired, ApplyMode::Replace);
|
||||
let mut diff = diff_global_settings(current, desired, ApplyMode::Replace);
|
||||
crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff).await?;
|
||||
apply_settings_diff(db, &diff).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -1495,6 +1532,10 @@ pub fn resolve_env_refs(settings: &mut GlobalSettings) -> Result<(), String> {
|
||||
resolve_env_option(&mut pg.user_pwd)?;
|
||||
}
|
||||
|
||||
if let Some(pg) = &mut settings.external_instance_pg {
|
||||
resolve_env_option(&mut pg.password)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2464,39 +2505,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_instance_replication_pwd_is_isolated_from_config() {
|
||||
// The replication-role password is server-only: written by setup/refresh via direct
|
||||
// SQL, never operator-authored. It must stay out of the declarative config surface
|
||||
// (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop it.
|
||||
assert!(HIDDEN_SETTINGS.contains(&"custom_instance_replication_pwd"));
|
||||
assert!(PROTECTED_SETTINGS.contains(&"custom_instance_replication_pwd"));
|
||||
assert!(SENSITIVE_SETTINGS.contains(&"custom_instance_replication_pwd"));
|
||||
fn server_generated_db_passwords_are_isolated_from_config() {
|
||||
// These hold passwords the server generates: written by setup/refresh via direct SQL,
|
||||
// never operator-authored. They must stay out of the declarative config surface
|
||||
// (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop them.
|
||||
for key in [
|
||||
"custom_instance_replication_pwd",
|
||||
"external_instance_pg_state",
|
||||
] {
|
||||
assert!(HIDDEN_SETTINGS.contains(&key), "{key}");
|
||||
assert!(PROTECTED_SETTINGS.contains(&key), "{key}");
|
||||
assert!(SENSITIVE_SETTINGS.contains(&key), "{key}");
|
||||
|
||||
// A stray desired value (e.g. flattened into `extra`) is ignored, not upserted.
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"custom_instance_replication_pwd".to_string(),
|
||||
serde_json::json!("attacker-set"),
|
||||
);
|
||||
let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge);
|
||||
assert!(
|
||||
diff.upserts.is_empty(),
|
||||
"hidden setting must not be upserted"
|
||||
);
|
||||
// A stray desired value (e.g. flattened into `extra`) is ignored, not upserted.
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(key.to_string(), serde_json::json!("attacker-set"));
|
||||
let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge);
|
||||
assert!(diff.upserts.is_empty(), "{key} must not be upserted");
|
||||
|
||||
// A current value is never deleted by a Replace that omits it.
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"custom_instance_replication_pwd".to_string(),
|
||||
serde_json::json!("live"),
|
||||
);
|
||||
let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace);
|
||||
assert!(
|
||||
!diff
|
||||
.deletes
|
||||
.contains(&"custom_instance_replication_pwd".to_string()),
|
||||
"hidden setting must not be deleted"
|
||||
);
|
||||
// A current value is never deleted by a Replace that omits it.
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(key.to_string(), serde_json::json!("live"));
|
||||
let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace);
|
||||
assert!(
|
||||
!diff.deletes.contains(&key.to_string()),
|
||||
"{key} must not be deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -58,6 +58,10 @@ pub mod ee_oss;
|
||||
pub mod email_ee;
|
||||
pub mod email_oss;
|
||||
pub mod error;
|
||||
pub mod external_instance_pg;
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod external_instance_pg_ee;
|
||||
pub mod external_instance_pg_oss;
|
||||
pub mod external_ip;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod feature_usage_ee;
|
||||
@@ -1024,6 +1028,20 @@ impl Future for TokioPgConnection {
|
||||
}
|
||||
}
|
||||
|
||||
impl TokioPgConnection {
|
||||
/// Drive the connection and hand back what the server sends outside of a query's response —
|
||||
/// notices above all, which driving it as a future silently discards.
|
||||
pub fn poll_message(
|
||||
&mut self,
|
||||
cx: &mut core::task::Context<'_>,
|
||||
) -> core::task::Poll<Option<Result<tokio_postgres::AsyncMessage, tokio_postgres::Error>>> {
|
||||
match self {
|
||||
TokioPgConnection::Tls(conn) => conn.poll_message(cx),
|
||||
TokioPgConnection::NoTls(conn) => conn.poll_message(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PgDatabase {
|
||||
/// The role the connection logs in as, whichever way it authenticates.
|
||||
pub fn login_name(&self) -> &str {
|
||||
@@ -1068,7 +1086,13 @@ impl PgDatabase {
|
||||
if err_str.contains("password authentication failed for user")
|
||||
&& err_str.contains("custom_instance_user")
|
||||
{
|
||||
if let Some(db) = main_db {
|
||||
// The external instance cluster has a `custom_instance_user` of its own, whose
|
||||
// password setup manages. Rotating the local one would break every instance
|
||||
// data table and fix nothing.
|
||||
let local = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
let on_local_cluster = local.host == self.host
|
||||
&& local.port.unwrap_or(5432) == self.port.unwrap_or(5432);
|
||||
if let Some(db) = main_db.filter(|_| on_local_cluster) {
|
||||
tracing::warn!(
|
||||
"custom_instance_user password auth failed, refreshing and retrying..."
|
||||
);
|
||||
@@ -1554,11 +1578,13 @@ pub async fn ensure_instance_db_grant_options_unchecked(
|
||||
}
|
||||
|
||||
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
|
||||
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake").
|
||||
/// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake"). `for_workspace`
|
||||
/// is the workspace a member creates a fork copy for; see [`ensure_fork_database_available_to`].
|
||||
pub async fn create_custom_instance_database(
|
||||
db: &DB,
|
||||
dbname: &str,
|
||||
tag: &str,
|
||||
for_workspace: Option<&str>,
|
||||
) -> error::Result<()> {
|
||||
let dbname = dbname.trim();
|
||||
validate_dbname(dbname)?;
|
||||
@@ -1612,7 +1638,8 @@ pub async fn create_custom_instance_database(
|
||||
},
|
||||
"success": true,
|
||||
"error": null,
|
||||
"tag": tag
|
||||
"tag": tag,
|
||||
"workspace_id": for_workspace,
|
||||
});
|
||||
sqlx::query!(
|
||||
r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#,
|
||||
@@ -1632,6 +1659,53 @@ pub async fn create_custom_instance_database(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse a workspace member writing a fork copy into, or pointing a fork at, the managed database
|
||||
/// `dbname` of `kind`, unless `w_id` created it for that ([`create_custom_instance_database`], or
|
||||
/// its external instance counterpart) and nothing uses it yet. The `wm_fork_` prefix is no
|
||||
/// authorization: every database of a cluster answers to the same `custom_instance_user`, so a name
|
||||
/// is all it takes to reach another workspace's copy.
|
||||
///
|
||||
/// Authorization: reads the global registries and every workspace's settings, and names other
|
||||
/// workspaces in its refusal. Callers MUST have authorized `w_id` for the caller first — a member
|
||||
/// of it forking or importing there — and MUST NOT call it on a workspace the caller is not in.
|
||||
pub async fn ensure_fork_database_available_to(
|
||||
db: &DB,
|
||||
kind: workspaces::DataTableCatalogResourceType,
|
||||
dbname: &str,
|
||||
w_id: &str,
|
||||
) -> error::Result<()> {
|
||||
let created_for = match kind {
|
||||
workspaces::DataTableCatalogResourceType::ExternalInstance => {
|
||||
external_instance_pg::external_instance_databases(db)
|
||||
.await?
|
||||
.remove(dbname)
|
||||
.and_then(|entry| entry.workspace_id)
|
||||
}
|
||||
_ => sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT value->'databases'->$1->>'workspace_id' FROM global_settings
|
||||
WHERE name = 'custom_instance_pg_databases'",
|
||||
)
|
||||
.bind(dbname)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten(),
|
||||
};
|
||||
if created_for.as_deref() != Some(w_id) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{dbname}' was not created for a fork of workspace '{w_id}'"
|
||||
)));
|
||||
}
|
||||
let uses =
|
||||
workspaces::managed_database_uses(&mut *db.acquire().await?, kind, dbname, None).await?;
|
||||
if !uses.is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Database '{dbname}' is already in use: {}",
|
||||
uses.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connection options parsed from a database URL.
|
||||
///
|
||||
/// The only place a database URL becomes `PgConnectOptions`. Providers that mint the password
|
||||
|
||||
@@ -329,6 +329,7 @@ pub fn try_expand_internal_db_query(
|
||||
"ALTER_TABLE" => expand_alter_table(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"CREATE_SCHEMA" => expand_create_schema(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"DROP_SCHEMA" => expand_drop_schema(json_str, db_type).map(ExpandedQuery::sql),
|
||||
"RENAME_SCHEMA" => expand_rename_schema(json_str, db_type).map(ExpandedQuery::sql),
|
||||
// Metadata queries
|
||||
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
|
||||
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
|
||||
@@ -1716,6 +1717,13 @@ struct DropSchemaPayload {
|
||||
ducklake: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RenameSchemaPayload {
|
||||
schema: String,
|
||||
new_schema: String,
|
||||
ducklake: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct TableEditorColumn {
|
||||
name: String,
|
||||
@@ -2004,6 +2012,23 @@ fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result<String, String>
|
||||
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
||||
}
|
||||
|
||||
fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: RenameSchemaPayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?;
|
||||
if !matches!(db_type, DbType::Postgresql | DbType::Snowflake) || p.ducklake.is_some() {
|
||||
return Err(format!(
|
||||
"Renaming a schema is not supported on {:?}",
|
||||
db_type
|
||||
));
|
||||
}
|
||||
let query = format!(
|
||||
"ALTER SCHEMA {} RENAME TO {};",
|
||||
qi(&p.schema, db_type),
|
||||
qi(&p.new_schema, db_type)
|
||||
);
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
fn expand_create_table(json_str: &str, db_type: DbType) -> Result<String, String> {
|
||||
let p: CreateTablePayload = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?;
|
||||
@@ -2598,7 +2623,9 @@ WHERE table_catalog = current_database()",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'".to_string(),
|
||||
// pg_catalog is readable by everyone: without the privilege check this lists
|
||||
// tables of schemas the connection's role cannot even enter.
|
||||
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE')".to_string(),
|
||||
",\n ns.nspname AS schema_name,\n c.relname AS table_name".to_string(),
|
||||
"\nJOIN pg_catalog.pg_class c ON a.attrelid = c.oid\nJOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid".to_string(),
|
||||
"ns.nspname, c.relname, a.attnum".to_string(),
|
||||
@@ -4101,6 +4128,13 @@ mod tests {
|
||||
assert_eq!(sql, "DROP SCHEMA \"old_schema\" CASCADE;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_rename_schema() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
||||
assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_create_schema_with_ducklake() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"s","ducklake":"lake"}"#;
|
||||
@@ -4468,6 +4502,7 @@ mod tests {
|
||||
assert!(sql.contains("schema_name"));
|
||||
assert!(sql.contains("table_name"));
|
||||
assert!(sql.contains("c.relkind = 'r'"));
|
||||
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Minimum length for a secret to be registered for masking.
|
||||
@@ -20,27 +20,9 @@ const MIN_SECRET_LENGTH: usize = 8;
|
||||
const MASKED_NOTICE: &str =
|
||||
"[windmill] secret value was masked for security reasons, use string transformations to display full value";
|
||||
|
||||
/// The secrets registered for one job, plus the automaton compiled from them.
|
||||
#[derive(Default)]
|
||||
struct JobMasks {
|
||||
secrets: HashSet<String>,
|
||||
/// Built on the first `snapshot` after a change and shared by every later
|
||||
/// snapshot. Every job registers at least its own token, so without this
|
||||
/// cache each log batch of each job would rebuild the automaton.
|
||||
compiled: Option<Arc<CompiledMasks>>,
|
||||
}
|
||||
|
||||
/// Aho-Corasick automaton for O(m) multi-pattern matching in a single pass,
|
||||
/// regardless of the number of secrets registered, with the replacement
|
||||
/// strings indexed to match the automaton's pattern order.
|
||||
struct CompiledMasks {
|
||||
ac: aho_corasick::AhoCorasick,
|
||||
replacements: Vec<String>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Map of job_id -> secret values that should be masked in that job's logs.
|
||||
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, JobMasks>> =
|
||||
/// Map of job_id -> set of secret values that should be masked in that job's logs.
|
||||
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, HashSet<String>>> =
|
||||
RwLock::new(HashMap::new());
|
||||
|
||||
/// Set of currently running job IDs on this worker process.
|
||||
@@ -50,8 +32,13 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
/// A lock-free snapshot of secrets for a job, taken once per log batch.
|
||||
/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass,
|
||||
/// regardless of the number of secrets registered.
|
||||
pub struct MaskSnapshot {
|
||||
compiled: Arc<CompiledMasks>,
|
||||
/// Aho-Corasick automaton for fast matching.
|
||||
ac: aho_corasick::AhoCorasick,
|
||||
/// Replacement strings, indexed to match the automaton's pattern order.
|
||||
replacements: Vec<String>,
|
||||
/// Whether the security notice has already been appended for this snapshot.
|
||||
/// Tracked locally to avoid a global write lock on every masked line.
|
||||
notice_shown: std::cell::Cell<bool>,
|
||||
@@ -66,104 +53,34 @@ impl MaskSnapshot {
|
||||
}
|
||||
|
||||
// Single-pass check + replace using the pre-built automaton
|
||||
if !self.compiled.ac.is_match(text) {
|
||||
if !self.ac.is_match(text) {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
|
||||
let mut result = self
|
||||
.compiled
|
||||
.ac
|
||||
.replace_all(text, &self.compiled.replacements);
|
||||
let mut result = self.ac.replace_all(text, &self.replacements);
|
||||
|
||||
// Append the notice only once per snapshot (i.e. per batch), as its own line.
|
||||
// Callers pass either a bare line (`handle_child`) or a chunk that already ends
|
||||
// in a newline (nativets), and the sinks concatenate what they get verbatim:
|
||||
// assuming either shape welds the notice onto a neighbouring line.
|
||||
// Append the notice only once per snapshot (i.e. per batch)
|
||||
if !self.notice_shown.get() {
|
||||
self.notice_shown.set(true);
|
||||
if result.ends_with('\n') {
|
||||
result.push_str(MASKED_NOTICE);
|
||||
result.push('\n');
|
||||
} else {
|
||||
result.push('\n');
|
||||
result.push_str(MASKED_NOTICE);
|
||||
}
|
||||
result.push('\n');
|
||||
result.push_str(MASKED_NOTICE);
|
||||
}
|
||||
|
||||
Cow::Owned(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// A masker for sinks that mask line by line rather than in batches, like nativets
|
||||
/// masking each `console.log` chunk as V8 produces it. `snapshot` per line would
|
||||
/// re-arm the security notice on every one; this keeps it to once per distinct set
|
||||
/// of secrets while still picking up secrets registered mid-run.
|
||||
///
|
||||
/// Masks by job id alone — the caller is the one that knows the text it passes
|
||||
/// belongs to that job.
|
||||
pub struct JobMasker {
|
||||
job_id: Uuid,
|
||||
snapshot: Option<MaskSnapshot>,
|
||||
}
|
||||
|
||||
impl JobMasker {
|
||||
pub fn new(job_id: Uuid) -> Self {
|
||||
JobMasker { job_id, snapshot: snapshot(&job_id) }
|
||||
}
|
||||
|
||||
/// Mask every secret registered for the job. Returns `Cow::Borrowed` when no match.
|
||||
/// Falls back to the masks it last saw once the job is unregistered, so a sink
|
||||
/// still draining past the end of a run does not start emitting secrets.
|
||||
pub fn mask<'a>(&mut self, text: &'a str) -> Cow<'a, str> {
|
||||
if let Some(fresh) = snapshot(&self.job_id) {
|
||||
// Replacing an equivalent snapshot would re-arm the notice, so only take
|
||||
// one built from a secret set we have not seen.
|
||||
let unchanged = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|cur| Arc::ptr_eq(&cur.compiled, &fresh.compiled));
|
||||
if !unchanged {
|
||||
self.snapshot = Some(fresh);
|
||||
}
|
||||
}
|
||||
match self.snapshot.as_ref() {
|
||||
Some(snapshot) => snapshot.mask(text),
|
||||
None => Cow::Borrowed(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a snapshot of the current secrets for a job. Returns `None` if no secrets
|
||||
/// are registered (the caller can then skip masking entirely for the whole batch).
|
||||
///
|
||||
/// Call this once per log batch in `write_lines`, not per line.
|
||||
pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
|
||||
{
|
||||
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
|
||||
let job = masks.get(job_id)?;
|
||||
if job.secrets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(compiled) = job.compiled.as_ref() {
|
||||
return Some(MaskSnapshot {
|
||||
compiled: compiled.clone(),
|
||||
notice_shown: std::cell::Cell::new(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
|
||||
let job = masks.get_mut(job_id)?;
|
||||
if job.secrets.is_empty() {
|
||||
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
|
||||
let secrets = masks.get(job_id)?;
|
||||
if secrets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let compiled = job
|
||||
.compiled
|
||||
.get_or_insert_with(|| Arc::new(compile(&job.secrets)));
|
||||
Some(MaskSnapshot { compiled: compiled.clone(), notice_shown: std::cell::Cell::new(false) })
|
||||
}
|
||||
|
||||
fn compile(secrets: &HashSet<String>) -> CompiledMasks {
|
||||
// Sort longest-first so longer secrets are matched before shorter substrings
|
||||
let mut sorted: Vec<&String> = secrets.iter().collect();
|
||||
sorted.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
@@ -189,7 +106,7 @@ fn compile(secrets: &HashSet<String>) -> CompiledMasks {
|
||||
.build(sorted.iter().map(|s| s.as_str()))
|
||||
.expect("failed to build aho-corasick automaton");
|
||||
|
||||
CompiledMasks { ac, replacements }
|
||||
Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) })
|
||||
}
|
||||
|
||||
/// Register a job as currently running. Call this before `handle_queued_job`.
|
||||
@@ -231,110 +148,20 @@ pub fn register_secret_for_all_running_jobs(secret: &str) {
|
||||
|
||||
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
|
||||
for job_id in job_ids {
|
||||
if let Some(job) = masks.get_mut(&job_id) {
|
||||
if job.secrets.insert(secret.to_string()) {
|
||||
job.compiled = None;
|
||||
}
|
||||
if let Some(set) = masks.get_mut(&job_id) {
|
||||
set.insert(secret.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a secret value for a specific job.
|
||||
/// Used for the job's own token and for `$encrypted:` args, where we know the job ID.
|
||||
/// Used for `$encrypted:` args where we know the job ID.
|
||||
pub fn register_secret_for_job(job_id: Uuid, secret: &str) {
|
||||
if secret.len() < MIN_SECRET_LENGTH {
|
||||
return;
|
||||
}
|
||||
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(job) = masks.get_mut(&job_id) {
|
||||
if job.secrets.insert(secret.to_string()) {
|
||||
job.compiled = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The compiled automaton is cached per job, so a secret registered after the
|
||||
/// first snapshot only gets masked if the cache is invalidated.
|
||||
#[test]
|
||||
fn snapshot_rebuilds_after_a_new_secret_is_registered() {
|
||||
let job_id = Uuid::new_v4();
|
||||
register_running_job(job_id);
|
||||
register_secret_for_job(job_id, "firstsecretvalue");
|
||||
let _ = snapshot(&job_id)
|
||||
.expect("secret registered")
|
||||
.mask("firstsecretvalue");
|
||||
|
||||
register_secret_for_job(job_id, "secondsecretvalue");
|
||||
|
||||
let snap = snapshot(&job_id).expect("secrets registered");
|
||||
let masked = snap.mask("firstsecretvalue then secondsecretvalue");
|
||||
assert!(!masked.contains("firstsecretvalue"), "{masked}");
|
||||
assert!(!masked.contains("secondsecretvalue"), "{masked}");
|
||||
unregister_running_job(job_id);
|
||||
}
|
||||
|
||||
/// A line-by-line sink must not repeat the notice on every line, and must still
|
||||
/// pick up a secret registered after the masker was built.
|
||||
#[test]
|
||||
fn job_masker_notices_once_per_secret_set() {
|
||||
let job_id = Uuid::new_v4();
|
||||
register_running_job(job_id);
|
||||
register_secret_for_job(job_id, "firstsecretvalue");
|
||||
let mut masker = JobMasker::new(job_id);
|
||||
|
||||
let first = masker.mask("saw firstsecretvalue").into_owned();
|
||||
assert!(!first.contains("firstsecretvalue"), "{first}");
|
||||
assert!(first.contains(MASKED_NOTICE), "{first}");
|
||||
|
||||
let second = masker.mask("saw firstsecretvalue again").into_owned();
|
||||
assert!(!second.contains("firstsecretvalue"), "{second}");
|
||||
assert!(!second.contains(MASKED_NOTICE), "{second}");
|
||||
|
||||
register_secret_for_job(job_id, "secondsecretvalue");
|
||||
let third = masker.mask("saw secondsecretvalue").into_owned();
|
||||
assert!(!third.contains("secondsecretvalue"), "{third}");
|
||||
unregister_running_job(job_id);
|
||||
}
|
||||
|
||||
/// Unregistration must not turn masking off under a sink that is still emitting:
|
||||
/// the masker keeps working off the masks it last saw rather than going quiet.
|
||||
#[test]
|
||||
fn job_masker_masks_after_the_job_is_unregistered() {
|
||||
let job_id = Uuid::new_v4();
|
||||
register_running_job(job_id);
|
||||
register_secret_for_job(job_id, "supersecretvalue");
|
||||
let mut masker = JobMasker::new(job_id);
|
||||
|
||||
unregister_running_job(job_id);
|
||||
|
||||
let masked = masker.mask("logged supersecretvalue here");
|
||||
assert!(!masked.contains("supersecretvalue"), "{masked}");
|
||||
}
|
||||
|
||||
/// The notice has to end up on a line of its own for both shapes callers pass:
|
||||
/// a bare line (`handle_child`) and a newline-terminated chunk (nativets). The
|
||||
/// sinks concatenate what they are given verbatim, so getting this wrong welds
|
||||
/// the notice onto whichever line follows it.
|
||||
#[test]
|
||||
fn notice_lands_on_its_own_line_for_both_caller_shapes() {
|
||||
let job_id = Uuid::new_v4();
|
||||
register_running_job(job_id);
|
||||
register_secret_for_job(job_id, "supersecretvalue");
|
||||
|
||||
let line = snapshot(&job_id)
|
||||
.expect("secret registered")
|
||||
.mask("tok supersecretvalue");
|
||||
assert_eq!(line, format!("tok s*****e\n{MASKED_NOTICE}"));
|
||||
|
||||
let chunk = snapshot(&job_id)
|
||||
.expect("secret registered")
|
||||
.mask("tok supersecretvalue\n");
|
||||
assert_eq!(chunk, format!("tok s*****e\n{MASKED_NOTICE}\n"));
|
||||
|
||||
unregister_running_job(job_id);
|
||||
if let Some(set) = masks.get_mut(&job_id) {
|
||||
set.insert(secret.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,19 +775,6 @@ pub fn make_pull_query(tags: &[String]) -> String {
|
||||
query
|
||||
}
|
||||
|
||||
/// Claim a parent's tool jobs without tag filtering. The caller must supply only child IDs
|
||||
/// it owns; this query is an internal scheduling primitive and does not authorize job access.
|
||||
pub fn make_tool_job_pull_query(job_ids: &[uuid::Uuid]) -> String {
|
||||
// pull() binds only the worker name. These literals come from typed UUIDs, never input SQL.
|
||||
let ids = job_ids.iter().map(|id| format!("'{id}'::uuid")).join(", ");
|
||||
format_pull_query(format!(
|
||||
"SELECT id FROM v2_job_queue
|
||||
WHERE running = false AND id = ANY(ARRAY[{ids}]::uuid[]) AND scheduled_for <= now()
|
||||
ORDER BY priority DESC NULLS LAST, scheduled_for
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1"
|
||||
))
|
||||
}
|
||||
|
||||
// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the
|
||||
// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing
|
||||
// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes
|
||||
|
||||
@@ -1397,6 +1397,18 @@ pub enum DataTableCatalogResourceType {
|
||||
#[strum(serialize = "postgres")]
|
||||
Postgresql,
|
||||
Instance,
|
||||
/// On the external instance cluster ([`crate::external_instance_pg`]). Enterprise Edition.
|
||||
#[serde(rename = "external_instance")]
|
||||
#[strum(serialize = "external_instance")]
|
||||
ExternalInstance,
|
||||
}
|
||||
|
||||
impl DataTableCatalogResourceType {
|
||||
/// A database Windmill created and administers, on its own cluster or the external one, as
|
||||
/// opposed to one a user brought as a resource.
|
||||
pub fn is_windmill_managed(self) -> bool {
|
||||
matches!(self, Self::Instance | Self::ExternalInstance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a self-teaching error for an unresolved `datatable://<name>` reference.
|
||||
@@ -1471,6 +1483,73 @@ pub struct GoverningDatatable {
|
||||
pub datatable: DataTable,
|
||||
}
|
||||
|
||||
/// Everything still using the Windmill-managed database `dbname`, one description per use: data
|
||||
/// table entries naming it, fork entries pointing at those, Ducklake catalogs on it, and fork
|
||||
/// Ducklake metadata schemas there that cleanup has not dropped yet. `exempt` is the one data table
|
||||
/// entry, `(workspace_id, name)`, the caller is about to stop using it through; pointers at that
|
||||
/// entry still count, since dropping the database would leave them resolving to nothing.
|
||||
///
|
||||
/// Authorization: reads every workspace's settings and checks nothing. Callers MUST only turn the
|
||||
/// answer into a refusal for someone allowed to administer `dbname`.
|
||||
pub async fn managed_database_uses(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
kind: DataTableCatalogResourceType,
|
||||
dbname: &str,
|
||||
exempt: Option<(&str, &str)>,
|
||||
) -> Result<Vec<String>> {
|
||||
let (exempt_workspace, exempt_name) = exempt.unzip();
|
||||
Ok(sqlx::query_scalar::<_, String>(
|
||||
"WITH entries AS (
|
||||
SELECT ws.workspace_id::text AS workspace_id, dt.key AS name, dt.value
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'
|
||||
THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt
|
||||
), naming AS (
|
||||
SELECT workspace_id, name FROM entries
|
||||
WHERE value->'database'->>'resource_type' = $1
|
||||
AND value->'database'->>'resource_path' = $2
|
||||
)
|
||||
SELECT format('data table ''%s'' in workspace ''%s''', name, workspace_id) FROM naming
|
||||
WHERE $3::text IS NULL OR NOT (workspace_id = $3 AND name = $4)
|
||||
UNION ALL
|
||||
SELECT format('data table ''%s'' in workspace ''%s'', which points at the one in ''%s''',
|
||||
e.name, e.workspace_id, n.workspace_id)
|
||||
FROM entries e JOIN naming n
|
||||
ON e.value->'reference'->>'workspace_id' = n.workspace_id
|
||||
AND e.value->'reference'->>'datatable' = n.name
|
||||
UNION ALL
|
||||
SELECT format('Ducklake ''%s'' in workspace ''%s''', dl.key, ws.workspace_id)
|
||||
FROM workspace_settings ws
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'
|
||||
THEN ws.ducklake->'ducklakes' ELSE '{}'::jsonb END) dl
|
||||
WHERE dl.value->'catalog'->>'resource_type' = $1
|
||||
AND dl.value->'catalog'->>'resource_path' = $2
|
||||
UNION ALL
|
||||
SELECT format('the Ducklake namespace of fork ''%s'', not cleaned up yet', workspace_id)
|
||||
FROM fork_ducklake_namespace
|
||||
WHERE catalog = $1 || ':' || $2 AND NOT schema_dropped
|
||||
ORDER BY 1",
|
||||
)
|
||||
.bind(kind.as_ref())
|
||||
.bind(dbname)
|
||||
.bind(exempt_workspace)
|
||||
.bind(exempt_name)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Held by fork cleanup of `w_id`'s data tables and by forking `w_id`, which can hand the new fork
|
||||
/// pointers at them, so a pointer cannot appear between cleanup's check and its drop.
|
||||
pub async fn lock_fork_datatables(conn: &mut sqlx::PgConnection, w_id: &str) -> Result<()> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork_datatables:' || $1))")
|
||||
.bind(w_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl GoverningDatatable {
|
||||
/// Backed by the Windmill instance's own Postgres, which is the only substrate data table
|
||||
/// roles apply to.
|
||||
@@ -1613,7 +1692,8 @@ pub async fn resolve_workspace_governing_datatables(
|
||||
}
|
||||
|
||||
/// Build the `admin` connection for a governing entry: `custom_instance_user` for an instance
|
||||
/// database, the user's own resource for a BYO-postgres one.
|
||||
/// database, on Windmill's cluster or the external one; the user's own resource for a BYO-postgres
|
||||
/// one.
|
||||
async fn resolve_datatable_connection_unchecked(
|
||||
db: &DB,
|
||||
governing: &GoverningDatatable,
|
||||
@@ -1624,7 +1704,16 @@ async fn resolve_datatable_connection_unchecked(
|
||||
.database
|
||||
.as_ref()
|
||||
.expect("a governing entry owns a database");
|
||||
if database.resource_type == DataTableCatalogResourceType::Instance {
|
||||
if database.resource_type == DataTableCatalogResourceType::ExternalInstance {
|
||||
let pg_creds = crate::external_instance_pg::external_instance_connection_unchecked(
|
||||
db,
|
||||
&database.resource_path,
|
||||
replication,
|
||||
)
|
||||
.await?;
|
||||
serde_json::to_value(&pg_creds)
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))
|
||||
} else if database.resource_type == DataTableCatalogResourceType::Instance {
|
||||
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
pg_creds.dbname = database.resource_path.clone();
|
||||
if replication {
|
||||
@@ -1967,6 +2056,8 @@ pub fn strip_datatable_permissions(
|
||||
/// As [`parse_datatable_ref`], except that an entry whose stored name itself contains `?` — which
|
||||
/// names could before they were restricted — resolves by that exact name, without a role. It is
|
||||
/// looked up first, so `sales?role=x` never reaches a different entry than the one stored so.
|
||||
/// When `sales` is stored too, the reference means either one, and is refused rather than
|
||||
/// resolved to whichever is looked up first.
|
||||
///
|
||||
/// Authorization: checks nothing, and its answer reveals whether `w_id` stores that exact name.
|
||||
/// Callers MUST already act for `w_id` — a job of it, or a caller authenticated into it — and
|
||||
@@ -1977,16 +2068,26 @@ pub async fn parse_datatable_ref_for(
|
||||
reference: &str,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
if reference.contains('?') {
|
||||
let exists = sqlx::query_scalar::<_, Option<bool>>(
|
||||
"SELECT (datatable->'datatables') ? $2 FROM workspace_settings WHERE workspace_id = $1",
|
||||
let role_target = parse_datatable_ref(reference)
|
||||
.ok()
|
||||
.and_then(|(name, role)| role.map(|_| name));
|
||||
let (exists, target_exists) = sqlx::query_as::<_, (Option<bool>, Option<bool>)>(
|
||||
"SELECT (datatable->'datatables') ? $2, (datatable->'datatables') ? $3
|
||||
FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(reference)
|
||||
.bind(role_target)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
if exists {
|
||||
.unwrap_or((None, None));
|
||||
if exists.unwrap_or(false) {
|
||||
if let (Some(name), Some(true)) = (role_target, target_exists) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Data table reference '{reference}' names both the data table '{reference}' \
|
||||
and a role on the data table '{name}'. Rename '{reference}' to use either."
|
||||
)));
|
||||
}
|
||||
return Ok((reference.to_string(), None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4399,45 +4399,6 @@ pub fn has_active_concurrency_limit(concurrent_limit: Option<i32>) -> bool {
|
||||
concurrent_limit.is_some_and(|n| n > 0)
|
||||
}
|
||||
|
||||
/// Admit a job already owned by a worker without releasing its queue reservation.
|
||||
/// The caller must maintain its heartbeat while waiting and complete it on failure.
|
||||
pub async fn try_admit_owned_job(db: &DB, job: &MiniPulledJob) -> error::Result<bool> {
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
{
|
||||
let settings = windmill_common::runnable_settings::prefetch_cached_from_handle(
|
||||
job.runnable_settings_handle,
|
||||
db,
|
||||
)
|
||||
.await?
|
||||
.1
|
||||
.maybe_fallback(None, job.concurrent_limit, job.concurrency_time_window_s);
|
||||
if has_active_concurrency_limit(settings.concurrent_limit)
|
||||
&& !*DISABLE_CONCURRENCY_LIMIT
|
||||
&& job.canceled_by.is_none()
|
||||
{
|
||||
let key = concurrency_key(db, &job.id).await?.ok_or_else(|| {
|
||||
Error::internal_err(format!("No concurrency key found for job {}", job.id))
|
||||
})?;
|
||||
if !key.is_empty() {
|
||||
return Ok(crate::jobs_ee::update_concurrency_counter(
|
||||
db,
|
||||
&job.id,
|
||||
key,
|
||||
serde_json::json!({ job.id.to_string(): {} }),
|
||||
job.id.to_string(),
|
||||
settings.concurrency_time_window_s.unwrap_or(0),
|
||||
settings.concurrent_limit.unwrap_or_default(),
|
||||
)
|
||||
.await?
|
||||
.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
let _ = (db, job);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn custom_concurrency_key(
|
||||
db: &Pool<Postgres>,
|
||||
job_id: &Uuid,
|
||||
@@ -4689,30 +4650,6 @@ pub fn tag_reads_args(tag: &str) -> bool {
|
||||
RE_ARG_TAG.is_match(tag)
|
||||
}
|
||||
|
||||
/// Whether the tag reads the flow's state (`$flow_expr[results.a.foo]`), which only the flow
|
||||
/// runtime can resolve, right before pushing the step. A malformed placeholder counts too, so it
|
||||
/// is rejected or dropped instead of queueing the job on its literal text.
|
||||
pub fn tag_reads_flow_expr(tag: &str) -> bool {
|
||||
tag.contains("$flow_expr[")
|
||||
}
|
||||
|
||||
/// Renders the value at the dotted `path` below `root` as a dynamic tag component, shared by
|
||||
/// `$args[...]` and `$flow_expr[...]`: its JSON text with surrounding quotes trimmed, and empty
|
||||
/// once a segment is missing. Only object keys are followed, never array indexes.
|
||||
pub fn render_tag_path(root: Option<&RawValue>, path: &str) -> String {
|
||||
let mut value = root.map(|x| x.get()).unwrap_or_default().to_string();
|
||||
for part in path.split('.').filter(|p| !p.is_empty()) {
|
||||
match serde_json::from_str::<serde_json::Value>(&value) {
|
||||
Ok(obj) => value = obj.get(part).map(|v| v.to_string()).unwrap_or_default(),
|
||||
Err(_) => {
|
||||
value = String::new();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
value.trim_matches('"').to_string()
|
||||
}
|
||||
|
||||
pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> String {
|
||||
// Save this value to avoid parsing twice
|
||||
let workspaced = x.as_str().replace("$workspace", workspace_id).to_string();
|
||||
@@ -4720,12 +4657,40 @@ pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> Strin
|
||||
let mut interpolated = workspaced.clone();
|
||||
for cap in RE_ARG_TAG.captures_iter(&workspaced) {
|
||||
let arg_name = cap.get(1).unwrap().as_str();
|
||||
let (root, rest) = arg_name.split_once('.').unwrap_or((arg_name, ""));
|
||||
let root_value = args
|
||||
.args
|
||||
.get(root)
|
||||
.or(args.extra.as_ref().and_then(|x| x.get(root)));
|
||||
let arg_value = render_tag_path(root_value.map(|x| &**x), rest);
|
||||
let arg_value = if arg_name.contains('.') {
|
||||
let parts: Vec<&str> = arg_name.split('.').collect();
|
||||
let root = parts[0];
|
||||
let mut value = args
|
||||
.args
|
||||
.get(root)
|
||||
.or(args.extra.as_ref().and_then(|x| x.get(root)))
|
||||
.map(|x| x.get())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
for part in parts.iter().skip(1) {
|
||||
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&value) {
|
||||
value = obj
|
||||
.get(part)
|
||||
.and_then(|v| Some(v.to_string()))
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.to_string();
|
||||
} else {
|
||||
value = "".to_string(); // Invalid JSON or missing field
|
||||
break;
|
||||
}
|
||||
}
|
||||
value.trim_matches('"').to_string()
|
||||
} else {
|
||||
args.args
|
||||
.get(arg_name)
|
||||
.or(args.extra.as_ref().and_then(|x| x.get(arg_name)))
|
||||
.map(|x| x.get())
|
||||
.unwrap_or_default()
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
};
|
||||
interpolated =
|
||||
interpolated.replace(format!("$args[{}]", arg_name).as_str(), &arg_value);
|
||||
}
|
||||
@@ -5304,8 +5269,6 @@ pub fn empty_result() -> Box<RawValue> {
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref RE_ARG_TAG: Regex = Regex::new(r#"\$args\[((?:\w+\.)*\w+)\]"#).unwrap();
|
||||
pub static ref RE_FLOW_EXPR_TAG: Regex =
|
||||
Regex::new(r#"\$flow_expr\[((?:\w+\.)*\w+)\]"#).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
@@ -6574,10 +6537,7 @@ async fn push_inner<'c, 'd>(
|
||||
);
|
||||
windmill_common::worker::dedicated_worker_tag(workspace_id, &full_path)
|
||||
} else {
|
||||
// The flow runtime resolves a step's `$flow_expr[...]` before pushing it, so one still here
|
||||
// was pushed with no flow state to read (a step test, a dependency job) and would name a
|
||||
// queue no worker serves: the job runs on its default tag instead.
|
||||
if tag == Some("".to_string()) || tag.as_deref().is_some_and(tag_reads_flow_expr) {
|
||||
if tag == Some("".to_string()) {
|
||||
tag = None;
|
||||
}
|
||||
|
||||
@@ -7962,43 +7922,3 @@ mod result_metadata_tests {
|
||||
assert_eq!(meta.wm_failure.as_deref(), Some("boom"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tag_path_tests {
|
||||
use super::{interpolate_args, render_tag_path, PushArgs};
|
||||
use serde_json::value::RawValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn render(root: &str, path: &str) -> String {
|
||||
render_tag_path(
|
||||
Some(&RawValue::from_string(root.to_string()).unwrap()),
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
// Existing `$args[...]` tags route on exactly these renderings.
|
||||
#[test]
|
||||
fn renders_like_args_tags() {
|
||||
assert_eq!(render(r#""eu""#, ""), "eu");
|
||||
assert_eq!(render(r#"{"a": {"b": "eu"}}"#, "a.b"), "eu");
|
||||
assert_eq!(render(r#"{"n": 4}"#, "n"), "4");
|
||||
assert_eq!(render("null", ""), "null");
|
||||
assert_eq!(render(r#"{"a": 1}"#, "b.c"), "");
|
||||
assert_eq!(render(r#"{"a": ["eu"]}"#, "a.0"), "");
|
||||
assert_eq!(render_tag_path(None, "a"), "");
|
||||
|
||||
let args = HashMap::from([("cfg".to_string(), raw(r#"{"lang": "eu"}"#))]);
|
||||
let push_args = PushArgs {
|
||||
args: &args,
|
||||
extra: Some(HashMap::from([("e".to_string(), raw(r#""x""#))])),
|
||||
};
|
||||
assert_eq!(
|
||||
interpolate_args("w-$args[cfg.lang]-$args[e]".to_string(), &push_args, "ws"),
|
||||
"w-eu-x"
|
||||
);
|
||||
}
|
||||
|
||||
fn raw(json: &str) -> Box<RawValue> {
|
||||
RawValue::from_string(json.to_string()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,11 +838,6 @@ pub async fn eval_fetch_timeout(
|
||||
}
|
||||
}
|
||||
let w_id_for_tracing = w_id_for_tracing;
|
||||
// nativets delivers logs in-process, so they never reach the masking in
|
||||
// `handle_child::write_lines` and a `console.log` of `$WM_TOKEN` would be
|
||||
// persisted verbatim. Mask here rather than in the detached task draining into
|
||||
// `append_logs`: this loop normally runs while the job is still registered.
|
||||
let mut masker = windmill_common::sensitive_log_masks::JobMasker::new(job_id);
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut result_stream = String::new();
|
||||
let mut is_stream = false;
|
||||
@@ -850,20 +845,10 @@ pub async fn eval_fetch_timeout(
|
||||
use windmill_common::result_stream::extract_stream_from_logs;
|
||||
use windmill_common::tracing_init::{OTEL_JOB_LOGS, OTEL_PREFIX};
|
||||
|
||||
let stream = extract_stream_from_logs(&log.trim_end_matches("\n"));
|
||||
|
||||
// A stream chunk is result data, not a log line — it never reaches
|
||||
// `job_logs`, and `merge_result_stream` can make it the job's result —
|
||||
// so it stays raw wherever it goes, here and in the mirror below.
|
||||
// Deliberately unlike `handle_child`, which streams the masked text.
|
||||
// Routed before masking because the notice is one-shot: spent on a chunk
|
||||
// no sink persists, a later redaction in `job_logs` would go unexplained.
|
||||
let logged = stream.is_none().then(|| masker.mask(&log).into_owned());
|
||||
|
||||
// Mirror `process_streaming_log_lines` (EE) + the OTEL_JOB_LOGS
|
||||
// hook from handle_child.rs, neither of which runs for nativets
|
||||
// since nativets delivers logs in-process via the log channel.
|
||||
for line in logged.as_deref().unwrap_or(&log).lines() {
|
||||
for line in log.lines() {
|
||||
tracing::info!(
|
||||
target: "windmill:job_log",
|
||||
job_id = ?job_id,
|
||||
@@ -877,7 +862,7 @@ pub async fn eval_fetch_timeout(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(stream) = stream {
|
||||
if let Some(stream) = extract_stream_from_logs(&log.trim_end_matches("\n")) {
|
||||
if !is_stream {
|
||||
is_stream = true;
|
||||
if let Some(ref f) = stream_notifier_update {
|
||||
@@ -889,8 +874,8 @@ pub async fn eval_fetch_timeout(
|
||||
if let Err(e) = result_stream_sender.send(stream) {
|
||||
tracing::error!("failed to send result stream: {e}");
|
||||
}
|
||||
} else if let Some(logged) = logged {
|
||||
if let Err(e) = append_logs_sender.send(logged) {
|
||||
} else {
|
||||
if let Err(e) = append_logs_sender.send(log) {
|
||||
tracing::error!("failed to send log: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ use crate::result_processor::handle_non_flow_job_error;
|
||||
use crate::worker_flow::{
|
||||
evaluate_input_transform, raw_script_to_payload, script_to_payload, JobPayloadWithTag,
|
||||
};
|
||||
use crate::{create_job_dir, handle_queued_job, JobCompletedSender};
|
||||
use crate::{
|
||||
create_job_dir, handle_queued_job, JobCompletedReceiver, JobCompletedSender, SendResult,
|
||||
SendResultPayload,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use mappable_rc::Marc;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::types::Json;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*};
|
||||
use windmill_common::jobs::JobPayload;
|
||||
@@ -34,11 +36,11 @@ use windmill_common::{
|
||||
flow_conversations::{MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModuleValue,
|
||||
worker::{make_tool_job_pull_query, to_raw_value, Connection},
|
||||
worker::{to_raw_value, Connection},
|
||||
};
|
||||
use windmill_queue::{
|
||||
get_mini_pulled_job, pull, push, try_admit_owned_job, MiniCompletedJob, MiniPulledJob,
|
||||
PushArgs, PushIsolationLevel,
|
||||
add_completed_job, add_completed_job_error, get_mini_pulled_job, push, MiniCompletedJob,
|
||||
MiniPulledJob, PushArgs, PushIsolationLevel,
|
||||
};
|
||||
|
||||
/// Shared collection of abort handles for spawned tool tasks.
|
||||
@@ -80,7 +82,6 @@ pub struct ToolExecutionContext<'a> {
|
||||
|
||||
// Abort handles for spawned tool tasks (used for force-cancel cleanup)
|
||||
pub tool_abort_handles: ToolAbortHandles,
|
||||
pub job_completed_tx: JobCompletedSender,
|
||||
}
|
||||
|
||||
/// Execute all tool calls from an AI response
|
||||
@@ -97,8 +98,7 @@ pub async fn execute_tool_calls(
|
||||
let mut used_structured_output_tool = false;
|
||||
let mut final_content = None;
|
||||
|
||||
let mut calls = tool_calls.iter().peekable();
|
||||
while let Some(tool_call) = calls.next() {
|
||||
for tool_call in tool_calls.iter() {
|
||||
// Stream tool call progress
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let event = StreamingEvent::ToolExecution {
|
||||
@@ -150,24 +150,15 @@ pub async fn execute_tool_calls(
|
||||
)
|
||||
.await?;
|
||||
} else if tool.module.is_some() {
|
||||
let mut batch = vec![(tool_call, tool)];
|
||||
while let Some(next_call) = calls.peek() {
|
||||
if structured_output_tool_name.as_deref()
|
||||
== Some(next_call.function.name.as_str())
|
||||
{
|
||||
break;
|
||||
}
|
||||
let Some(next_tool) = tools.iter().find(|t| {
|
||||
t.def.function.name == next_call.function.name
|
||||
&& t.mcp_source.is_none()
|
||||
&& t.module.is_some()
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
batch.push((calls.next().unwrap(), next_tool));
|
||||
}
|
||||
execute_windmill_tools(&mut ctx, &batch, actions, &mut messages, final_events_str)
|
||||
.await?;
|
||||
execute_windmill_tool(
|
||||
&mut ctx,
|
||||
tool_call,
|
||||
tool,
|
||||
actions,
|
||||
&mut messages,
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
return Err(Error::internal_err(format!(
|
||||
"Tool type not supported: {}",
|
||||
@@ -322,13 +313,14 @@ async fn execute_mcp_tool_call(
|
||||
}
|
||||
|
||||
/// Execute a Windmill tool (script or flow)
|
||||
async fn enqueue_windmill_tool(
|
||||
ctx: &ToolExecutionContext<'_>,
|
||||
async fn execute_windmill_tool(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
tool_call: &OpenAIToolCall,
|
||||
tool: &Tool,
|
||||
actions: &mut Vec<AgentAction>,
|
||||
reserved: bool,
|
||||
) -> Result<Uuid, Error> {
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
// Regular Windmill tools must have a module
|
||||
let tool_module = tool.module.as_ref().ok_or_else(|| {
|
||||
Error::internal_err(format!("Tool {} has no module", tool_call.function.name))
|
||||
@@ -410,6 +402,8 @@ async fn enqueue_windmill_tool(
|
||||
tool_call_args.insert(key.clone(), result);
|
||||
}
|
||||
|
||||
let is_ai_agent_tool = matches!(tool_value, FlowModuleValue::AIAgent { .. });
|
||||
|
||||
let job_payload = match tool_value {
|
||||
FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => {
|
||||
script_to_payload(
|
||||
@@ -479,6 +473,8 @@ async fn enqueue_windmill_tool(
|
||||
));
|
||||
}
|
||||
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
|
||||
// tool jobs are pushed with the parent agent job's tag and executed inline on the
|
||||
// same worker, so a tag override on a nested agent tool does not apply here
|
||||
JobPayloadWithTag {
|
||||
payload: JobPayload::AIAgent { path },
|
||||
tag: None,
|
||||
@@ -536,52 +532,44 @@ async fn enqueue_windmill_tool(
|
||||
false,
|
||||
None,
|
||||
ctx.job.visible_to_owner,
|
||||
job_payload.tag,
|
||||
Some(ctx.job.tag.clone()),
|
||||
job_payload.timeout,
|
||||
None,
|
||||
job_priority,
|
||||
job_perms.as_ref(),
|
||||
reserved,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tx = tx;
|
||||
if reserved {
|
||||
// Running ownership reserves the first child; its normal tag allows zombie recovery.
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET worker = $1 WHERE id = $2",
|
||||
ctx.worker_name,
|
||||
uuid,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!("UPDATE v2_job_runtime SET ping = now() WHERE id = $1", uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
fn spawn_local_tool(
|
||||
ctx: &ToolExecutionContext<'_>,
|
||||
tool_job: MiniPulledJob,
|
||||
reserved: bool,
|
||||
) -> tokio::task::JoinHandle<Result<OccupancyMetrics, Error>> {
|
||||
let mut tool_job = Arc::new(tool_job);
|
||||
let tool_job = get_mini_pulled_job(ctx.db, &uuid).await?;
|
||||
|
||||
let Some(tool_job) = tool_job else {
|
||||
return Err(Error::internal_err("Tool job not found".to_string()));
|
||||
};
|
||||
|
||||
let tool_job = Arc::new(tool_job);
|
||||
|
||||
let (inner_job_completed_tx, inner_job_completed_rx) = JobCompletedSender::new(ctx.conn, 1);
|
||||
|
||||
let inner_job_completed_rx = inner_job_completed_rx.expect(
|
||||
"inner_job_completed_tx should be set as agent jobs are not supported on agent workers",
|
||||
);
|
||||
|
||||
// Spawn handle_queued_job on separate task to prevent tokio stack overflow
|
||||
// Clone everything needed for the spawned task
|
||||
let db = ctx.db.clone();
|
||||
let tool_job_spawn = tool_job.clone();
|
||||
let conn_spawn = ctx.conn.clone();
|
||||
let client_spawn = ctx.client.clone();
|
||||
let hostname_spawn = ctx.hostname.to_string();
|
||||
let worker_name_spawn = ctx.worker_name.to_string();
|
||||
let worker_dir_spawn = ctx.worker_dir.to_string();
|
||||
let base_internal_url_spawn = ctx.base_internal_url.to_string();
|
||||
let job_completed_tx = ctx.job_completed_tx.clone();
|
||||
let inner_job_completed_tx_spawn = inner_job_completed_tx.clone();
|
||||
let mut occupancy_metrics_spawn = ctx.occupancy_metrics.clone();
|
||||
let mut killpill_rx_spawn = ctx.killpill_rx.resubscribe();
|
||||
|
||||
@@ -590,264 +578,145 @@ fn spawn_local_tool(
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench_spawn = windmill_common::bench::BenchmarkIter::new();
|
||||
|
||||
let result = async {
|
||||
if reserved {
|
||||
loop {
|
||||
let queued = get_mini_pulled_job(&db, &tool_job.id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::AlreadyCompleted("Tool job already completed".to_string()))?;
|
||||
tool_job = Arc::new(queued);
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_runtime SET ping = now() WHERE id = $1",
|
||||
tool_job.id,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
if try_admit_owned_job(&db, &tool_job).await? {
|
||||
let started_at = sqlx::query_scalar!(
|
||||
"UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at",
|
||||
tool_job.id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
Arc::make_mut(&mut tool_job).started_at = started_at;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
let perms =
|
||||
windmill_common::auth::get_job_perms(&db, &tool_job.id, &tool_job.workspace_id).await?;
|
||||
let token = windmill_queue::create_token(&db, &tool_job, perms).await;
|
||||
let client_spawn = AuthedClient::new(
|
||||
base_internal_url_spawn.clone(),
|
||||
tool_job.workspace_id.clone(),
|
||||
token,
|
||||
None,
|
||||
);
|
||||
let job_dir = create_job_dir(&worker_dir_spawn, tool_job.id).await;
|
||||
let job_dir = create_job_dir(&worker_dir_spawn, tool_job_spawn.id).await;
|
||||
|
||||
handle_queued_job(
|
||||
tool_job.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&conn_spawn,
|
||||
&client_spawn,
|
||||
&hostname_spawn,
|
||||
&worker_name_spawn,
|
||||
&worker_dir_spawn,
|
||||
&job_dir,
|
||||
None,
|
||||
&base_internal_url_spawn,
|
||||
job_completed_tx,
|
||||
&mut occupancy_metrics_spawn,
|
||||
&mut killpill_rx_spawn,
|
||||
None,
|
||||
None,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench_spawn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
let result = handle_queued_job(
|
||||
tool_job_spawn,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&conn_spawn,
|
||||
&client_spawn,
|
||||
&hostname_spawn,
|
||||
&worker_name_spawn,
|
||||
&worker_dir_spawn,
|
||||
&job_dir,
|
||||
None,
|
||||
&base_internal_url_spawn,
|
||||
inner_job_completed_tx_spawn,
|
||||
&mut occupancy_metrics_spawn,
|
||||
&mut killpill_rx_spawn,
|
||||
None,
|
||||
None,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench_spawn,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(err) => {
|
||||
let err_string = format!("{}: {}", err.name(), err);
|
||||
handle_non_flow_job_error(
|
||||
&db,
|
||||
&MiniCompletedJob::from(tool_job),
|
||||
0,
|
||||
None,
|
||||
err_string,
|
||||
windmill_common::worker::error_to_value(&err),
|
||||
&worker_name_spawn,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
Ok(occupancy_metrics_spawn)
|
||||
// Return both result and updated metrics
|
||||
(result, occupancy_metrics_spawn)
|
||||
});
|
||||
|
||||
// Register abort handle so the task can be killed on force-cancel
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
|
||||
ctx.tool_abort_handles.lock().unwrap().push(abort_handle);
|
||||
join_handle
|
||||
}
|
||||
|
||||
async fn execute_windmill_tools(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
batch: &[(&OpenAIToolCall, &Tool)],
|
||||
actions: &mut Vec<AgentAction>,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let mut job_ids = Vec::with_capacity(batch.len());
|
||||
let mut local = None;
|
||||
for (index, (call, tool)) in batch.iter().enumerate() {
|
||||
if index > 0 {
|
||||
if let Some(processor) = ctx.stream_event_processor {
|
||||
processor
|
||||
.send(
|
||||
StreamingEvent::ToolExecution {
|
||||
call_id: call.id.clone(),
|
||||
function_name: call.function.name.clone(),
|
||||
},
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// Await the spawned task
|
||||
let (handle_result, updated_occupancy) = join_handle.await.map_err(|e| {
|
||||
if e.is_cancelled() {
|
||||
Error::ExecutionErr("Tool execution task was cancelled".to_string())
|
||||
} else {
|
||||
Error::internal_err(format!("Tool execution task failed: {}", e))
|
||||
}
|
||||
job_ids.push(enqueue_windmill_tool(ctx, call, tool, actions, index == 0).await?);
|
||||
if index == 0 {
|
||||
let job = get_mini_pulled_job(ctx.db, &job_ids[0])
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err("Reserved tool job not found".to_string()))?;
|
||||
local = Some(spawn_local_tool(ctx, job, true));
|
||||
})?;
|
||||
|
||||
// Merge occupancy metrics back
|
||||
ctx.occupancy_metrics.total_duration_of_running_jobs =
|
||||
updated_occupancy.total_duration_of_running_jobs;
|
||||
|
||||
match handle_result {
|
||||
Err(err) => {
|
||||
handle_tool_execution_error(
|
||||
ctx,
|
||||
tool_call,
|
||||
tool_module,
|
||||
&MiniCompletedJob::from(tool_job),
|
||||
job_id,
|
||||
err,
|
||||
messages,
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(outcome) => {
|
||||
handle_tool_execution_success(
|
||||
ctx,
|
||||
tool_call,
|
||||
tool_module,
|
||||
job_id,
|
||||
outcome.is_success(),
|
||||
is_ai_agent_tool,
|
||||
inner_job_completed_rx,
|
||||
messages,
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut results: HashMap<Uuid, (bool, String)> = HashMap::new();
|
||||
let mut next_result = 0;
|
||||
let mut poll = tokio::time::interval(Duration::from_millis(100));
|
||||
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
while next_result < batch.len() || local.is_some() {
|
||||
tokio::select! {
|
||||
result = async { local.as_mut().unwrap().await }, if local.is_some() => {
|
||||
local = None;
|
||||
let metrics = result.map_err(|e| Error::internal_err(format!("Tool task failed: {e}")))??;
|
||||
ctx.occupancy_metrics.total_duration_of_running_jobs = metrics.total_duration_of_running_jobs;
|
||||
ctx.tool_abort_handles.lock().unwrap().retain(|handle| !handle.is_finished());
|
||||
}
|
||||
_ = poll.tick() => {}
|
||||
}
|
||||
|
||||
let pending: Vec<Uuid> = job_ids[next_result..]
|
||||
.iter()
|
||||
.filter(|id| !results.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
if !pending.is_empty() {
|
||||
let completed = sqlx::query!(
|
||||
"SELECT id, status = 'success' AS \"success!\", result AS \"result: Json<Box<RawValue>>\"
|
||||
FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)",
|
||||
ctx.job.workspace_id,
|
||||
&pending,
|
||||
).fetch_all(ctx.db).await?;
|
||||
for completed in completed {
|
||||
let index = job_ids
|
||||
.iter()
|
||||
.position(|id| *id == completed.id)
|
||||
.ok_or_else(|| Error::internal_err("Unexpected tool completion".to_string()))?;
|
||||
let (call, tool) = batch[index];
|
||||
let result = completed
|
||||
.result
|
||||
.map(|value| value.0)
|
||||
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
|
||||
let is_agent = tool.module.as_ref().is_some_and(|module| {
|
||||
matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. }))
|
||||
});
|
||||
let content = if is_agent && completed.success {
|
||||
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
|
||||
} else {
|
||||
result.get().to_string()
|
||||
};
|
||||
if let Some(processor) = ctx.stream_event_processor {
|
||||
processor
|
||||
.send(
|
||||
StreamingEvent::ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
function_name: call.function.name.clone(),
|
||||
result: content.clone(),
|
||||
success: completed.success,
|
||||
},
|
||||
final_events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
results.insert(completed.id, (completed.success, content));
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript rows, model messages, and positional action statuses share call order.
|
||||
while next_result < batch.len() {
|
||||
let job_id = job_ids[next_result];
|
||||
let Some((success, content)) = results.remove(&job_id) else {
|
||||
break;
|
||||
};
|
||||
let (call, tool) = batch[next_result];
|
||||
let module = tool
|
||||
.module
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::internal_err("Windmill tool has no module".to_string()))?;
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(content.clone())),
|
||||
tool_call_id: Some(call.id.clone()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id,
|
||||
function_name: call.function.name.clone(),
|
||||
module_id: module.id.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(parent) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent, success).await?;
|
||||
}
|
||||
let (content, extras) = windmill_tool_row(call, success, &content);
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
|
||||
next_result += 1;
|
||||
}
|
||||
|
||||
if local.is_none() && next_result < batch.len() {
|
||||
let pending: Vec<Uuid> = job_ids[next_result..]
|
||||
.iter()
|
||||
.filter(|id| !results.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
if !pending.is_empty() {
|
||||
local = claim_local_tool(ctx, &pending).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn claim_local_tool(
|
||||
ctx: &ToolExecutionContext<'_>,
|
||||
pending: &[Uuid],
|
||||
) -> Result<Option<tokio::task::JoinHandle<Result<OccupancyMetrics, Error>>>, Error> {
|
||||
let query = (String::new(), make_tool_job_pull_query(pending));
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench = windmill_common::bench::BenchmarkIter::new();
|
||||
let mut pulled = pull(
|
||||
/// Handle tool execution error
|
||||
async fn handle_tool_execution_error(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
tool_call: &OpenAIToolCall,
|
||||
tool_module: &windmill_common::flows::FlowModule,
|
||||
tool_job: &MiniCompletedJob,
|
||||
job_id: Uuid,
|
||||
err: Error,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let err_string = format!("{}: {}", err.name(), err.to_string());
|
||||
let err_json = windmill_common::worker::error_to_value(&err);
|
||||
let _ = handle_non_flow_job_error(
|
||||
ctx.db,
|
||||
false,
|
||||
tool_job,
|
||||
0,
|
||||
None,
|
||||
err_string.clone(),
|
||||
err_json,
|
||||
ctx.worker_name,
|
||||
Some(&query),
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
)
|
||||
.await?;
|
||||
if let Err(err) = pulled.maybe_apply_debouncing(ctx.db).await {
|
||||
pulled.error_while_preprocessing = Some(err.to_string());
|
||||
.await;
|
||||
|
||||
let error_message = format!("Error running tool: {}", err_string);
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(error_message.clone())),
|
||||
tool_call_id: Some(tool_call.id.clone()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id,
|
||||
function_name: tool_call.function.name.clone(),
|
||||
module_id: tool_module.id.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Stream tool result (error case)
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let tool_result_event = StreamingEvent::ToolResult {
|
||||
call_id: tool_call.id.clone(),
|
||||
function_name: tool_call.function.name.clone(),
|
||||
result: error_message.clone(),
|
||||
success: false,
|
||||
};
|
||||
stream_event_processor
|
||||
.send(tool_result_event, final_events_str)
|
||||
.await?;
|
||||
}
|
||||
match pulled.to_pulled_job() {
|
||||
Ok(job) => Ok(job.map(|job| spawn_local_tool(ctx, job.job, false))),
|
||||
Err(
|
||||
windmill_queue::PulledJobResultToJobErr::MissingConcurrencyKey(job)
|
||||
| windmill_queue::PulledJobResultToJobErr::ErrorWhilePreprocessing(job),
|
||||
) => {
|
||||
ctx.job_completed_tx.send_job(job, true).await?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?;
|
||||
}
|
||||
|
||||
let (content, extras) = windmill_tool_row(tool_call, false, &error_message);
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, false, Some(extras)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the `output` field of an `AIAgentResult` envelope, serialized back to JSON.
|
||||
@@ -859,6 +728,119 @@ fn extract_ai_agent_output(result: &RawValue) -> Option<String> {
|
||||
.map(|output| output.get().to_string())
|
||||
}
|
||||
|
||||
/// Handle tool execution success
|
||||
async fn handle_tool_execution_success(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
tool_call: &OpenAIToolCall,
|
||||
tool_module: &windmill_common::flows::FlowModule,
|
||||
job_id: Uuid,
|
||||
success: bool,
|
||||
is_ai_agent_tool: bool,
|
||||
inner_job_completed_rx: JobCompletedReceiver,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok();
|
||||
|
||||
let (result, job_success) = if let Some(SendResult {
|
||||
result: SendResultPayload::JobCompleted(ref jc),
|
||||
..
|
||||
}) = send_result
|
||||
{
|
||||
let result = jc.result.clone();
|
||||
// Write tool completion to the DB inline instead of forwarding through
|
||||
// the parent channel. Forwarding would deadlock for nested agents: the
|
||||
// sub-tool result would fill the parent's bounded(1) channel, leaving
|
||||
// no room for the agent's own completion from process_result.
|
||||
if jc.success {
|
||||
add_completed_job(
|
||||
ctx.db,
|
||||
&jc.job,
|
||||
true,
|
||||
false,
|
||||
sqlx::types::Json(&*jc.result),
|
||||
jc.result_columns.clone(),
|
||||
jc.mem_peak,
|
||||
jc.canceled_by.clone(),
|
||||
false,
|
||||
jc.duration,
|
||||
jc.from_cache.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?;
|
||||
} else {
|
||||
let error_value: serde_json::Value =
|
||||
serde_json::from_str(jc.result.get()).unwrap_or_else(|_| {
|
||||
serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) })
|
||||
});
|
||||
add_completed_job_error(
|
||||
ctx.db,
|
||||
&jc.job,
|
||||
jc.mem_peak,
|
||||
jc.canceled_by.clone(),
|
||||
error_value,
|
||||
ctx.worker_name,
|
||||
false,
|
||||
jc.duration,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?;
|
||||
}
|
||||
(result, jc.success)
|
||||
} else {
|
||||
return Err(Error::internal_err(
|
||||
"Tool job completed but no result".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries
|
||||
// the child's entire message history, stream log and token usage. Feeding that back would
|
||||
// grow the caller's context by the child's full transcript on every call, so the caller only
|
||||
// sees `output`. The envelope stays intact in the tool job's completed row.
|
||||
let tool_result = if is_ai_agent_tool && job_success {
|
||||
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
|
||||
} else {
|
||||
result.get().to_string()
|
||||
};
|
||||
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(tool_result.clone())),
|
||||
tool_call_id: Some(tool_call.id.clone()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id,
|
||||
function_name: tool_call.function.name.clone(),
|
||||
module_id: tool_module.id.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let (content, extras) = windmill_tool_row(tool_call, success, &tool_result);
|
||||
|
||||
// The job ran; whether it ran successfully is `success`, and the row stored below is
|
||||
// worded from it. The stream has to carry the same value, or the card the reader watches
|
||||
// and the row that replaces it describe the same call differently.
|
||||
if let Some(stream_event_processor) = ctx.stream_event_processor {
|
||||
let tool_result_event = StreamingEvent::ToolResult {
|
||||
call_id: tool_call.id.clone(),
|
||||
function_name: tool_call.function.name.clone(),
|
||||
result: tool_result,
|
||||
success,
|
||||
};
|
||||
stream_event_processor
|
||||
.send(tool_result_event, final_events_str)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(parent_job) = ctx.parent_job {
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?;
|
||||
}
|
||||
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Windmill tool's conversation row: worded from the tool, carrying the model's call and
|
||||
/// the exact text the model got back, the same text agent memory keeps for that tool
|
||||
/// message, so a card needs no job fetch. The call is the model's arguments, not the job's
|
||||
@@ -923,7 +905,9 @@ async fn add_tool_message_to_chat(
|
||||
.or(ctx.job.flow_step_id.as_deref());
|
||||
let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id);
|
||||
|
||||
// created_seq defines transcript order, so these writes must stay sequential.
|
||||
// Awaited, not spawned: `created_seq` is the transcript's order, so a round's rows
|
||||
// must commit in the order of its calls. Calls run one after another; running them
|
||||
// in parallel would need their rows written in call order all the same.
|
||||
if let Err(e) = add_message_to_conversation(
|
||||
ctx.db,
|
||||
&memory_id,
|
||||
|
||||
@@ -79,15 +79,6 @@ lazy_static::lazy_static! {
|
||||
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
|
||||
|
||||
/// What a run stopped by `max_iterations` reports back. `Message` rather than
|
||||
/// `OpenAIMessage` is load-bearing: `agent_action` is `skip_serializing` on the
|
||||
/// latter and reaches JSON only through this wrapper, so serializing these raw
|
||||
/// drops every tool name and job id and leaves the partial run unreadable.
|
||||
#[derive(serde::Serialize)]
|
||||
struct MaxIterPartialResult<'a> {
|
||||
messages: Vec<Message<'a>>,
|
||||
}
|
||||
|
||||
fn strip_system_messages(messages: &[OpenAIMessage]) -> Vec<OpenAIMessage> {
|
||||
messages
|
||||
.iter()
|
||||
@@ -504,7 +495,6 @@ pub async fn handle_ai_agent_job(
|
||||
hostname: &str,
|
||||
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
|
||||
has_stream: &mut bool,
|
||||
job_completed_tx: crate::JobCompletedSender,
|
||||
) -> Result<Box<RawValue>, Error> {
|
||||
// build_args_map returns None if no $res:/$var: transforms needed, in which case use original args
|
||||
let local_args = match build_args_map(job, client, conn).await? {
|
||||
@@ -1011,7 +1001,6 @@ pub async fn handle_ai_agent_job(
|
||||
omit_output_from_conversation,
|
||||
cancel_rx,
|
||||
tool_abort_handles.clone(),
|
||||
job_completed_tx,
|
||||
);
|
||||
|
||||
let mut occupancy_opt = Some(occupancy_metrics);
|
||||
@@ -1030,10 +1019,13 @@ pub async fn handle_ai_agent_job(
|
||||
cancel_tx,
|
||||
CANCEL_GRACE_PERIOD,
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
};
|
||||
// agent_fut and update_job are now dropped — borrows on mcp_clients and canceled_by released
|
||||
|
||||
// Cleanup MCP clients
|
||||
cleanup_mcp_clients(mcp_clients).await;
|
||||
|
||||
let format_cancel_info = |cb: &Option<CanceledBy>| {
|
||||
cb.as_ref()
|
||||
.map_or(("unknown".to_string(), "unknown".to_string()), |x| {
|
||||
@@ -1044,42 +1036,38 @@ pub async fn handle_ai_agent_job(
|
||||
})
|
||||
};
|
||||
|
||||
let result = match outcome {
|
||||
Err(error) => Err(error),
|
||||
Ok(outcome) => match outcome {
|
||||
GracefulPollOutcome::Ok(result) => Ok(result),
|
||||
GracefulPollOutcome::Timeout(ms) => {
|
||||
tracing::error!("AI agent timeout after {}s", ms / 1000);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"AI agent timeout after (>{}s)",
|
||||
ms / 1000
|
||||
)))
|
||||
match outcome {
|
||||
GracefulPollOutcome::Ok(result) => Ok(result),
|
||||
GracefulPollOutcome::Timeout(ms) => {
|
||||
tracing::error!("AI agent timeout after {}s", ms / 1000);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"AI agent timeout after (>{}s)",
|
||||
ms / 1000
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason})"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
// Abort any still-running spawned tool tasks
|
||||
// unwrap safe: lock is only held briefly for push/drain, no panic possible inside
|
||||
for handle in tool_abort_handles.lock().unwrap().drain(..) {
|
||||
handle.abort();
|
||||
}
|
||||
GracefulPollOutcome::Cancelled { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason})"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => {
|
||||
let (by, reason) = format_cancel_info(&cb);
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::AlreadyCompleted => {
|
||||
Err(Error::AlreadyCompleted("Job already completed".to_string()))
|
||||
}
|
||||
},
|
||||
};
|
||||
if result.is_err() {
|
||||
for handle in tool_abort_handles.lock().unwrap().drain(..) {
|
||||
handle.abort();
|
||||
// Hard timeout: clean up orphaned jobs still stuck in v2_job_queue
|
||||
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, cb).await;
|
||||
Err(Error::ExecutionErr(format!(
|
||||
"Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)"
|
||||
)))
|
||||
}
|
||||
GracefulPollOutcome::AlreadyCompleted => {
|
||||
Err(Error::AlreadyCompleted("Job already completed".to_string()))
|
||||
}
|
||||
cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, canceled_by.clone()).await;
|
||||
}
|
||||
cleanup_mcp_clients(mcp_clients).await;
|
||||
result
|
||||
}
|
||||
|
||||
/// OpenAI rejects a `prompt_cache_key` over 64 characters
|
||||
@@ -1132,7 +1120,6 @@ pub async fn run_agent(
|
||||
|
||||
// abort handles for spawned tool tasks
|
||||
tool_abort_handles: ToolAbortHandles,
|
||||
job_completed_tx: crate::JobCompletedSender,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
|
||||
let credentials = args.provider.to_provider_credentials(db).await?;
|
||||
@@ -1798,6 +1785,10 @@ pub async fn run_agent(
|
||||
step_id: Option<&'a str>,
|
||||
result: MaxIterPartialResult<'a>,
|
||||
}
|
||||
#[derive(serde::Serialize)]
|
||||
struct MaxIterPartialResult<'a> {
|
||||
messages: &'a [OpenAIMessage],
|
||||
}
|
||||
return Err(Error::ExecutionRawError(
|
||||
serde_json::value::to_raw_value(&MaxIterError {
|
||||
message: format!(
|
||||
@@ -1806,15 +1797,7 @@ pub async fn run_agent(
|
||||
),
|
||||
name: "ExecutionErr",
|
||||
step_id: effective_flow_step_id,
|
||||
result: MaxIterPartialResult {
|
||||
messages: messages
|
||||
.iter()
|
||||
.map(|m| Message {
|
||||
message: m,
|
||||
agent_action: m.agent_action.as_ref(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
result: MaxIterPartialResult { messages: &messages },
|
||||
})?,
|
||||
));
|
||||
}
|
||||
@@ -1863,7 +1846,6 @@ pub async fn run_agent(
|
||||
previous_result: &previous_result,
|
||||
id_context: &id_context,
|
||||
tool_abort_handles: tool_abort_handles.clone(),
|
||||
job_completed_tx: job_completed_tx.clone(),
|
||||
};
|
||||
|
||||
let (tool_messages, tool_content, tool_used_structured_output) =
|
||||
@@ -2340,33 +2322,6 @@ mod tests {
|
||||
assert!(!streaming_requested(Some(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_iterations_partial_result_keeps_the_action_tags() {
|
||||
let messages = vec![OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text("{\"rows\":2}".to_string())),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
agent_action: Some(AgentAction::ToolCall {
|
||||
job_id: uuid::Uuid::nil(),
|
||||
function_name: "list_payouts".to_string(),
|
||||
module_id: "b".to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let partial = MaxIterPartialResult {
|
||||
messages: messages
|
||||
.iter()
|
||||
.map(|m| Message { message: m, agent_action: m.agent_action.as_ref() })
|
||||
.collect(),
|
||||
};
|
||||
let json = serde_json::to_value(&partial).unwrap();
|
||||
|
||||
let action = &json["messages"][0]["agent_action"];
|
||||
assert_eq!(action["type"], "tool_call");
|
||||
assert_eq!(action["function_name"], "list_payouts");
|
||||
}
|
||||
|
||||
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
|
||||
/// trip per run and silently leaves that step with no prompt caching at all.
|
||||
#[test]
|
||||
@@ -2763,13 +2718,11 @@ async fn cleanup_orphaned_tool_jobs(
|
||||
)
|
||||
});
|
||||
|
||||
// Find direct child jobs still in v2_job_queue (agent tool jobs are always direct children)
|
||||
let orphaned_ids: Vec<Uuid> = match sqlx::query_scalar!(
|
||||
r#"WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2
|
||||
UNION ALL
|
||||
SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id
|
||||
WHERE j.workspace_id = $2
|
||||
) SELECT d.id AS "id!" FROM descendants d JOIN v2_job_queue q ON q.id = d.id"#,
|
||||
r#"SELECT j.id FROM v2_job j
|
||||
JOIN v2_job_queue q ON q.id = j.id
|
||||
WHERE j.parent_job = $1 AND j.workspace_id = $2"#,
|
||||
parent_job_id,
|
||||
w_id,
|
||||
)
|
||||
|
||||
@@ -3859,6 +3859,8 @@ pub async fn run_worker(
|
||||
|
||||
let arc_job = Arc::new(job);
|
||||
|
||||
windmill_common::sensitive_log_masks::register_running_job(arc_job.id);
|
||||
|
||||
let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job");
|
||||
let log_ctx = log_context_for_job(&arc_job, &worker_name, Some(hostname));
|
||||
|
||||
@@ -3974,6 +3976,8 @@ pub async fn run_worker(
|
||||
_ => {}
|
||||
}
|
||||
|
||||
windmill_common::sensitive_log_masks::unregister_running_job(job_id);
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(duration) = _timer.map(|x| x.stop_and_record()) {
|
||||
register_metric(
|
||||
@@ -4508,30 +4512,6 @@ async fn detect_and_store_runtime_assets_from_job_args(
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds a job's entry in the log-masking registry for as long as it executes, so
|
||||
/// that secrets it fetches can be registered against it, and masks the job's own
|
||||
/// token from the start: `$WM_TOKEN` stays valid well past the run, and a script
|
||||
/// that echoes it would otherwise leave a live credential in the persisted logs.
|
||||
///
|
||||
/// Lives here rather than at the call sites so that every way of running a job —
|
||||
/// the poller, the interactive worker shell, an inline AI agent tool — is covered
|
||||
/// by construction.
|
||||
struct RunningJobMasks(Uuid);
|
||||
|
||||
impl RunningJobMasks {
|
||||
fn register(job_id: Uuid, token: &str) -> Self {
|
||||
windmill_common::sensitive_log_masks::register_running_job(job_id);
|
||||
windmill_common::sensitive_log_masks::register_secret_for_job(job_id, token);
|
||||
RunningJobMasks(job_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningJobMasks {
|
||||
fn drop(&mut self) {
|
||||
windmill_common::sensitive_log_masks::unregister_running_job(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_queued_job(
|
||||
job: Arc<MiniPulledJob>,
|
||||
raw_code: Option<String>,
|
||||
@@ -4553,8 +4533,6 @@ pub async fn handle_queued_job(
|
||||
flow_runners: Option<Arc<FlowRunners>>,
|
||||
#[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter,
|
||||
) -> windmill_common::error::Result<JobOutcome> {
|
||||
let _masks = RunningJobMasks::register(job.id, &client.token);
|
||||
|
||||
if job.canceled_by.is_some() {
|
||||
return Err(Error::JsonErr(canceled_job_to_result(&job)));
|
||||
}
|
||||
@@ -4973,7 +4951,6 @@ pub async fn handle_queued_job(
|
||||
hostname,
|
||||
killpill_rx,
|
||||
&mut has_stream,
|
||||
job_completed_tx.clone(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ use windmill_common::{
|
||||
use windmill_queue::schedule::get_schedule_opt;
|
||||
use windmill_queue::{
|
||||
add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job,
|
||||
insert_concurrency_key_capped, interpolate_args, render_tag_path,
|
||||
insert_concurrency_key_capped, interpolate_args,
|
||||
report_error_to_workspace_handler_or_critical_side_channel, tag_reads_args,
|
||||
tag_reads_flow_expr, try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob,
|
||||
MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError, RE_FLOW_EXPR_TAG,
|
||||
try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob, MiniPulledJob, PushArgs,
|
||||
PushIsolationLevel, SameWorkerPayload, WrappedError,
|
||||
};
|
||||
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
@@ -3115,66 +3115,6 @@ fn resolve_flow_step_tag(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves each `$flow_expr[root.key.path]` of a step tag by reading `key.path` from `results`
|
||||
/// (where `key` is a step id), `flow_input` or `flow_env`, rendered as `$args[key.path]` would be.
|
||||
async fn interpolate_flow_expr_tag(
|
||||
tag: &str,
|
||||
db: &DB,
|
||||
flow_job: &MiniPulledJob,
|
||||
flow_input: &HashMap<String, Box<RawValue>>,
|
||||
flow_env: Option<&HashMap<String, Box<RawValue>>>,
|
||||
) -> error::Result<String> {
|
||||
if RE_FLOW_EXPR_TAG
|
||||
.replace_all(tag, "")
|
||||
.contains("$flow_expr[")
|
||||
{
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Could not resolve the step tag `{tag}`: each `$flow_expr[...]` must hold a dotted \
|
||||
path such as `results.a.b.c`"
|
||||
)));
|
||||
}
|
||||
let mut rendered: HashMap<&str, String> = HashMap::new();
|
||||
for cap in RE_FLOW_EXPR_TAG.captures_iter(tag) {
|
||||
let path = cap.get(1).unwrap().as_str();
|
||||
if rendered.contains_key(path) {
|
||||
continue;
|
||||
}
|
||||
let (root, key_path) = path.split_once('.').unwrap_or((path, ""));
|
||||
let (key, rest) = key_path.split_once('.').unwrap_or((key_path, ""));
|
||||
if key.is_empty() || !matches!(root, "results" | "flow_input" | "flow_env") {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Could not resolve the step tag `{tag}`: `{path}` must start with \
|
||||
`results.<step_id>`, `flow_input.<key>` or `flow_env.<key>`"
|
||||
)));
|
||||
}
|
||||
let value = match root {
|
||||
"flow_input" => render_tag_path(flow_input.get(key).map(|x| &**x), rest),
|
||||
"flow_env" => render_tag_path(flow_env.and_then(|e| e.get(key)).map(|x| &**x), rest),
|
||||
_ => match windmill_queue::get_result_by_id(
|
||||
db.clone(),
|
||||
flow_job.workspace_id.clone(),
|
||||
flow_job.id,
|
||||
key.to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => render_tag_path(Some(&*result), rest),
|
||||
Err(Error::NotFound(_)) => String::new(),
|
||||
Err(e) => {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Could not resolve the step tag `{tag}`: {e}"
|
||||
)))
|
||||
}
|
||||
},
|
||||
};
|
||||
rendered.insert(path, value);
|
||||
}
|
||||
Ok(RE_FLOW_EXPR_TAG
|
||||
.replace_all(tag, |cap: ®ex::Captures| rendered[&cap[1]].clone())
|
||||
.into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tag_resolution_tests {
|
||||
use super::resolve_flow_step_tag;
|
||||
@@ -4253,10 +4193,6 @@ async fn push_next_flow_job(
|
||||
None
|
||||
};
|
||||
|
||||
// The `flow_input` the step's input transforms read, which a `$flow_expr[flow_input...]`
|
||||
// tag must read too: the body of a simple for-loop also sees `iter` there.
|
||||
let mut step_flow_input = arc_flow_job_args.clone();
|
||||
|
||||
let marc;
|
||||
let me;
|
||||
let args = match &next_status {
|
||||
@@ -4281,10 +4217,8 @@ async fn push_next_flow_job(
|
||||
if let Some(input_transforms) = simple_input_transforms {
|
||||
//previous id is none because we do not want to use previous id if we are in a for loop
|
||||
let ctx = get_transform_context(&flow_job, "", &status);
|
||||
let args = Marc::new(args);
|
||||
step_flow_input = args.clone();
|
||||
let ti = transform_input(
|
||||
args,
|
||||
Marc::new(args),
|
||||
flow_env,
|
||||
arc_last_job_result.clone(),
|
||||
input_transforms,
|
||||
@@ -4463,21 +4397,17 @@ async fn push_next_flow_job(
|
||||
payload_tag.tag.as_deref(),
|
||||
);
|
||||
|
||||
// A step whose inputs failed to evaluate, or whose `$flow_expr[...]` tag failed to resolve,
|
||||
// is pushed only to report the error, and a computed tag can then name a queue nobody
|
||||
// serves (`push_args` is empty, so `$args[...]` reads nothing), leaving the step stuck
|
||||
// instead. Send it to the flow's tag, which a worker is provably serving right now.
|
||||
// `push_args` is empty once the input transforms failed, so a tag reading `$args[...]`
|
||||
// interpolates to a queue nobody serves and the step sits there instead of reporting
|
||||
// the error. Send it to the flow's tag, which a worker is provably serving right now.
|
||||
//
|
||||
// A step handed over by id, or one whose tag `push` replaces, never reaches a worker
|
||||
// through its tag, so rewriting theirs would be noise.
|
||||
let step_is_pulled_by_tag = !continue_on_same_worker
|
||||
&& !continue_with_runners
|
||||
&& !payload_tag.payload.is_dedicated_worker();
|
||||
let reroute_to_flow_tag = err.is_some()
|
||||
&& step_is_pulled_by_tag
|
||||
&& tag
|
||||
.as_deref()
|
||||
.is_some_and(|t| tag_reads_args(t) || tag_reads_flow_expr(t));
|
||||
let reroute_to_flow_tag =
|
||||
err.is_some() && step_is_pulled_by_tag && tag.as_deref().is_some_and(tag_reads_args);
|
||||
let tag = if reroute_to_flow_tag {
|
||||
Some(flow_job.tag.clone())
|
||||
} else {
|
||||
@@ -4519,30 +4449,6 @@ async fn push_next_flow_job(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Resolved only after the check: CUSTOM_TAGS allows the template, so its value may name
|
||||
// any queue, as the value of an `$args[...]` tag does.
|
||||
let mut tag_err = None;
|
||||
let tag = match tag {
|
||||
Some(t) if err.is_none() && tag_reads_flow_expr(&t) => {
|
||||
match interpolate_flow_expr_tag(&t, db, &flow_job, &step_flow_input, flow_env)
|
||||
.warn_after_seconds(3)
|
||||
.await
|
||||
{
|
||||
Ok(resolved) => Some(resolved),
|
||||
Err(e) => {
|
||||
tag_err = Some(e);
|
||||
Some(if step_is_pulled_by_tag {
|
||||
flow_job.tag.clone()
|
||||
} else {
|
||||
t
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
t => t,
|
||||
};
|
||||
let err = err.or(tag_err.as_ref());
|
||||
|
||||
let evaluated_timeout = if let Some(timeout_transform) = &module.timeout {
|
||||
let ctx = get_transform_context(&flow_job, &previous_id, &status);
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.815.0";
|
||||
export const VERSION = "v1.814.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+2465
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"version": "1.815.0",
|
||||
"version": "1.814.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-chat",
|
||||
"version": "1.815.0",
|
||||
"version": "1.814.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/react": "^4.0.102",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-chat",
|
||||
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
|
||||
"version": "1.815.0",
|
||||
"version": "1.814.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ const packageJson = {
|
||||
url: "https://github.com/windmill-labs/windmill/issues",
|
||||
},
|
||||
dependencies: {
|
||||
esbuild: "0.28.2",
|
||||
esbuild: "0.28.0",
|
||||
...Object.fromEntries(parserPackages.map(p => [p, cliDeps[p] ?? "*"])),
|
||||
},
|
||||
optionalDependencies: {
|
||||
|
||||
+30
-30
@@ -11,7 +11,7 @@
|
||||
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
|
||||
"@windmill-labs/shared-utils": "^1.0.13",
|
||||
"diff": "^5.2.0",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild": "0.28.0",
|
||||
"get-port": "7.1.0",
|
||||
"jszip": "3.8.0",
|
||||
"minimatch": "^10.0.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"windmill-parser-wasm-ts": "1.695.0",
|
||||
"windmill-parser-wasm-yaml": "1.770.0",
|
||||
"windmill-yaml-validator": "1.1.1",
|
||||
"ws": "8.21.3",
|
||||
"ws": "8.18.0",
|
||||
"yaml": "^2.7.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -57,57 +57,57 @@
|
||||
|
||||
"@cliffy/table": ["@jsr/cliffy__table@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", { "dependencies": { "@jsr/std__fmt": "^1.0.9" } }, "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
"diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
"esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": "bin/esbuild" }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
"windmill-yaml-validator": ["windmill-yaml-validator@1.1.1", "", { "dependencies": { "@stoplight/yaml": "^4.3.0", "ajv": "^8.17.1" } }, "sha512-CVgAwEoBdJhF39q2N012QffhlGPRIyIWd8gj7NnfG+/lMWgH2k5CBLtKIt6cPF8Bxz+6DGC3st1ARSsecDtbTg=="],
|
||||
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
"@cliffy/table": "npm:@jsr/cliffy__table@1.0.0",
|
||||
"@windmill-labs/shared-utils": "^1.0.13",
|
||||
"diff": "^5.2.0",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild": "0.28.0",
|
||||
"get-port": "7.1.0",
|
||||
"jszip": "3.8.0",
|
||||
"minimatch": "^10.0.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"windmill-parser-wasm-ts": "1.695.0",
|
||||
"windmill-parser-wasm-yaml": "1.770.0",
|
||||
"windmill-yaml-validator": "1.1.1",
|
||||
"ws": "8.21.3",
|
||||
"ws": "8.18.0",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface AppFile {
|
||||
tables?: string[];
|
||||
datatable?: string;
|
||||
schema?: string;
|
||||
/** The role the app uses each data table through, by data table name. */
|
||||
roles?: Record<string, string>;
|
||||
};
|
||||
// Mirrors granular ACLs on the raw_app path. Synced via /acls/* by
|
||||
// applyExtraPermsDiff — never through update_app_raw — so a perm-only
|
||||
|
||||
@@ -2546,9 +2546,9 @@ export function preservePendingScriptLocks(
|
||||
}
|
||||
|
||||
// `sync push` never applies the workspace's display name from settings.yaml and
|
||||
// applies its color and auto_invite.instance_groups only when the local file
|
||||
// carries them (see pushWorkspaceSettings), so on a push the fields it would not
|
||||
// apply must compare equal, or the row is listed on every run.
|
||||
// applies its color only when the local file carries one (see
|
||||
// pushWorkspaceSettings), so on a push the fields it would not apply must
|
||||
// compare equal, or the row is listed on every run.
|
||||
const isWorkspaceSettingsFile = (p: string) =>
|
||||
/^settings(\.[^./\\]+)?\.(yaml|json)$/.test(p);
|
||||
function stripUnappliedSettingsFields(local: any, remote: any) {
|
||||
@@ -2558,20 +2558,6 @@ function stripUnappliedSettingsFields(local: any, remote: any) {
|
||||
delete local?.color;
|
||||
delete remote?.color;
|
||||
}
|
||||
// push reads a missing auto_invite as {} on both sides
|
||||
if (local) local.auto_invite ??= {};
|
||||
if (remote) remote.auto_invite ??= {};
|
||||
const localInvite = local?.auto_invite;
|
||||
const remoteInvite = remote?.auto_invite;
|
||||
if (localInvite?.instance_groups == null) {
|
||||
for (const invite of [localInvite, remoteInvite]) {
|
||||
delete invite?.instance_groups;
|
||||
delete invite?.instance_groups_roles;
|
||||
}
|
||||
} else {
|
||||
localInvite.instance_groups_roles ??= {};
|
||||
if (remoteInvite) remoteInvite.instance_groups_roles ??= {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function compareDynFSElement(
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.815.0";
|
||||
export const VERSION = "1.814.0";
|
||||
|
||||
@@ -239,19 +239,8 @@ export async function pushWorkspaceSettings(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle auto_invite using grouped format. The domain invite and the instance groups
|
||||
// are applied by separate endpoints, each rewriting only its own keys.
|
||||
const {
|
||||
instance_groups: localGroups,
|
||||
instance_groups_roles: localGroupRoles,
|
||||
...localDomainInvite
|
||||
} = localSettings.auto_invite ?? {};
|
||||
const {
|
||||
instance_groups: remoteGroups,
|
||||
instance_groups_roles: remoteGroupRoles,
|
||||
...remoteDomainInvite
|
||||
} = settings.auto_invite ?? {};
|
||||
if (!deepEqual(localDomainInvite, remoteDomainInvite)) {
|
||||
// Handle auto_invite using grouped format
|
||||
if (!deepEqual(localSettings.auto_invite, settings.auto_invite)) {
|
||||
log.debug(`Updating auto invite...`);
|
||||
|
||||
const localAutoInvite = localSettings.auto_invite;
|
||||
@@ -289,20 +278,6 @@ export async function pushWorkspaceSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Only when settings.yaml declares instance_groups: clearing a group removes the
|
||||
// workspace members it granted, so an absent key must never clear it.
|
||||
if (
|
||||
localGroups != undefined &&
|
||||
(!deepEqual(localGroups, remoteGroups) ||
|
||||
!deepEqual(localGroupRoles ?? {}, remoteGroupRoles ?? {}))
|
||||
) {
|
||||
log.debug(`Updating instance groups...`);
|
||||
await wmill.editInstanceGroups({
|
||||
workspace,
|
||||
requestBody: { groups: localGroups, roles: localGroupRoles ?? {} },
|
||||
});
|
||||
}
|
||||
|
||||
if (!deepEqual(localSettings.ai_config, settings.ai_config)) {
|
||||
log.debug(`Updating copilot settings...`);
|
||||
await wmill.editCopilotConfig({
|
||||
|
||||
Generated
+4
@@ -5826,6 +5826,8 @@ data:
|
||||
tables:
|
||||
- main/users # Table in public schema
|
||||
- main/app_schema:items # Table in specific schema
|
||||
roles: # Optional: the role the app uses each datatable through
|
||||
main: analyst
|
||||
\`\`\`
|
||||
|
||||
**Table reference formats:**
|
||||
@@ -5833,6 +5835,8 @@ data:
|
||||
- \`<datatable>/<table>\` — Specific table in public schema
|
||||
- \`<datatable>/<schema>:<table>\` — Table in specific schema
|
||||
|
||||
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role.
|
||||
|
||||
## SQL Migrations (sql_to_apply/)
|
||||
|
||||
The \`sql_to_apply/\` folder is for creating/modifying database tables during development.
|
||||
|
||||
@@ -25,7 +25,7 @@ type Esbuild = typeof import("esbuild");
|
||||
|
||||
// Version to fall back to if the native host's version can't be read. Keep in
|
||||
// sync with the "esbuild" pin in cli/package.json.
|
||||
const FALLBACK_VERSION = "0.28.2";
|
||||
const FALLBACK_VERSION = "0.28.0";
|
||||
|
||||
let cached: Esbuild | undefined;
|
||||
let inFlight: Promise<Esbuild> | undefined;
|
||||
|
||||
@@ -320,29 +320,3 @@ test("push: settings.yaml differing only by name or an unset color is not a chan
|
||||
});
|
||||
expect(await diff(otherColor, remote, skips)).toEqual(["edited settings.yaml"]);
|
||||
});
|
||||
|
||||
// A push applies auto_invite.instance_groups only when the local file declares
|
||||
// them (see pushWorkspaceSettings).
|
||||
test("push: settings.yaml without instance_groups is not a change", async () => {
|
||||
const remote = local({
|
||||
"settings.yaml":
|
||||
"name: prod\nauto_invite:\n enabled: false\n instance_groups:\n - eng\n instance_groups_roles:\n eng: developer\n",
|
||||
});
|
||||
const undeclared = local({
|
||||
"settings.yaml": "name: prod\nauto_invite:\n enabled: false\n",
|
||||
});
|
||||
const skips = { includeSettings: true };
|
||||
expect(await diff(undeclared, remote, skips)).toEqual([]);
|
||||
|
||||
const groupsOnlyRemote = local({
|
||||
"settings.yaml": "name: prod\nauto_invite:\n instance_groups:\n - eng\n",
|
||||
});
|
||||
const noAutoInvite = local({ "settings.yaml": "name: prod\n" });
|
||||
expect(await diff(noAutoInvite, groupsOnlyRemote, skips)).toEqual([]);
|
||||
|
||||
const otherGroups = local({
|
||||
"settings.yaml":
|
||||
"name: prod\nauto_invite:\n enabled: false\n instance_groups: []\n",
|
||||
});
|
||||
expect(await diff(otherGroups, remote, skips)).toEqual(["edited settings.yaml"]);
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Regression guard: `sync push` (pushWorkspaceSettings) applies the domain invite and
|
||||
* the instance groups of `auto_invite` through their own endpoints, and never clears
|
||||
* instance groups that settings.yaml does not declare.
|
||||
*/
|
||||
|
||||
import { expect, test, describe, beforeEach, mock } from "bun:test";
|
||||
|
||||
let editAutoInviteCalls: unknown[] = [];
|
||||
let editInstanceGroupsCalls: unknown[] = [];
|
||||
const remoteAutoInvite = {
|
||||
enabled: true,
|
||||
domain: "*",
|
||||
operator: false,
|
||||
mode: "invite",
|
||||
instance_groups: ["eng"],
|
||||
instance_groups_roles: { eng: "developer" },
|
||||
};
|
||||
|
||||
// Every wmill.* call reachable from pushWorkspaceSettings is stubbed: bun shares one
|
||||
// mocked module across test files, and names missing from whichever mock loads first
|
||||
// stay missing for the others.
|
||||
mock.module("../gen/services.gen.ts", () => ({
|
||||
getSettings: async () => ({ auto_invite: remoteAutoInvite }),
|
||||
getWorkspaceName: async () => "phoenix",
|
||||
changeWorkspaceName: async () => {},
|
||||
changeWorkspaceColor: async () => {},
|
||||
editWebhook: async () => {},
|
||||
editAutoInvite: async (a: unknown) => {
|
||||
editAutoInviteCalls.push(a);
|
||||
},
|
||||
editInstanceGroups: async (a: unknown) => {
|
||||
editInstanceGroupsCalls.push(a);
|
||||
},
|
||||
editErrorHandler: async () => {},
|
||||
editSuccessHandler: async () => {},
|
||||
editCopilotConfig: async () => {},
|
||||
editLargeFileStorageConfig: async () => {},
|
||||
editWorkspaceGitSyncConfig: async () => {},
|
||||
editWorkspaceDefaultApp: async () => {},
|
||||
editDefaultScripts: async () => {},
|
||||
workspaceMuteCriticalAlertsUi: async () => {},
|
||||
updateOperatorSettings: async () => {},
|
||||
editDataTableConfig: async () => {},
|
||||
editSlackCommand: async () => {},
|
||||
setWorkspaceSlackOauthConfig: async () => {},
|
||||
deleteWorkspaceSlackOauthConfig: async () => {},
|
||||
}));
|
||||
|
||||
const { pushWorkspaceSettings } = await import("../src/core/settings.ts");
|
||||
|
||||
describe("pushWorkspaceSettings auto_invite", () => {
|
||||
beforeEach(() => {
|
||||
editAutoInviteCalls = [];
|
||||
editInstanceGroupsCalls = [];
|
||||
});
|
||||
|
||||
test("an instance-group-only change updates the groups and leaves the domain invite", async () => {
|
||||
await pushWorkspaceSettings("phoenix", "settings", undefined, {
|
||||
name: "phoenix",
|
||||
auto_invite: { ...remoteAutoInvite, instance_groups_roles: { eng: "admin" } },
|
||||
});
|
||||
expect(editAutoInviteCalls.length).toBe(0);
|
||||
expect(editInstanceGroupsCalls).toEqual([
|
||||
{
|
||||
workspace: "phoenix",
|
||||
requestBody: { groups: ["eng"], roles: { eng: "admin" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a settings.yaml without instance_groups does not clear them", async () => {
|
||||
const { instance_groups: _g, instance_groups_roles: _r, ...domainInvite } =
|
||||
remoteAutoInvite;
|
||||
await pushWorkspaceSettings("phoenix", "settings", undefined, {
|
||||
name: "phoenix",
|
||||
auto_invite: { ...domainInvite, operator: true },
|
||||
});
|
||||
expect(editAutoInviteCalls.length).toBe(1);
|
||||
expect(editInstanceGroupsCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,6 @@ mock.module("../gen/services.gen.ts", () => ({
|
||||
editWebhookCalls.push(a);
|
||||
},
|
||||
editAutoInvite: async () => {},
|
||||
editInstanceGroups: async () => {},
|
||||
editErrorHandler: async () => {},
|
||||
editSuccessHandler: async () => {},
|
||||
editCopilotConfig: async () => {},
|
||||
|
||||
@@ -110,12 +110,11 @@ WORKDIR /multiplayer
|
||||
|
||||
# Copy multiplayer server files
|
||||
COPY multiplayer/package.json .
|
||||
COPY multiplayer/package-lock.json .
|
||||
COPY multiplayer/server.mjs .
|
||||
COPY multiplayer/gateway.mjs .
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm install
|
||||
|
||||
# ============================================================================
|
||||
# Entrypoint Setup
|
||||
|
||||
+10
-34
@@ -12,36 +12,14 @@ Symbols, not line numbers, are cited: they drift less.
|
||||
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
|
||||
mints one for any non-job token but returns plain text, no redirect.
|
||||
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
|
||||
- **`max_token_expiration_days`** caps `POST /users/tokens/create` and `tokens/impersonate`, by
|
||||
shortening the stored expiration (`cap_token_expiration`), never by refusing: the CLI
|
||||
authorization page, `wmill user create-token` and the editor's language-server token all pick a
|
||||
lifetime without reading the setting, and CLIs already installed never will. The CLI signs in
|
||||
again on its own when its token expires, which is why the authorization page labels it
|
||||
`cli-login:<username>`, reserved in `is_user_token` so its expiry does not email the user. A token
|
||||
owned by a service account is exempt: one in the workspace the token names, or in any workspace
|
||||
for a workspace-less token (for `tokens/impersonate`, the impersonated account). Any workspace
|
||||
admin can therefore create and impersonate a service account to hold an uncapped token, so the
|
||||
ceiling bounds personal tokens only. Only the stored expiration is capped: the auth lookup never
|
||||
reads the setting, so tokens that exist when it is turned on or lowered keep theirs, including
|
||||
none. Deliberately outside it: server-side mints (`create_token_internal` callers such as native
|
||||
trigger webhook tokens, which never expire for GitHub and Nextcloud), and tokens with their own
|
||||
fixed lifetime that outlive a short ceiling: sessions (`MAX_SESSION_VALIDITY_SECONDS`, 3 days, and
|
||||
re-mintable through `GET /users/refresh_token`) and MCP OAuth access tokens (7 days, with a
|
||||
rotating 30-day refresh token). Any logged-in user can read the setting through `GET
|
||||
/settings/global/{key}`, which the token form uses to offer only expirations within it. The
|
||||
settings API and config sync reject any value `parse_max_token_expiration_days` cannot read, since
|
||||
the token routes would read it as no ceiling; `parseMaxTokenExpirationDays` in the frontend must
|
||||
accept exactly the same values.
|
||||
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items` removes
|
||||
an expired `token` row, the monitor emails the owner and raises a critical alert (if enabled);
|
||||
rows registered by `register_token_expiry_notification` also get an "expiring soon" warning first,
|
||||
except a token whose whole lifetime fits in `TOKEN_EXPIRY_WARNING_DAYS` (7), which gets no row
|
||||
since the warning would arrive minutes after it was created. Neither happens when `is_user_token`
|
||||
(`windmill-common/src/auth.rs`) reserves the label, so a token the system mints for itself,
|
||||
whether from the backend or from the frontend through `tokens/create`, needs a reserved label. An
|
||||
`ephemeral-` prefix needs no other change (keep it clear of `is_server_minted_label` if minted
|
||||
through `tokens/create`); a new prefix also goes into the SQL and Svelte mirrors that function's
|
||||
doc lists.
|
||||
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items`
|
||||
removes an expired `token` row, the monitor emails the owner and raises a critical alert (if
|
||||
enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon"
|
||||
warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves
|
||||
the label, so a token the system mints for itself, whether from the backend or from the frontend
|
||||
through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change
|
||||
(keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix
|
||||
also goes into the SQL and Svelte mirrors that function's doc lists.
|
||||
- **Every superadmin route refuses a job token**: `require_super_admin`
|
||||
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
|
||||
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
|
||||
@@ -51,10 +29,8 @@ Symbols, not line numbers, are cited: they drift less.
|
||||
the account into a `password` one in the same statement (an account created ahead of its owner
|
||||
gets its first credential that way, or through the OAuth claim below).
|
||||
- **Login links** (`login_link` table, `POST /users/login_links` superadmin-only,
|
||||
`GET /auth/login_link/{token}` unauthenticated): single-use, ≤2 h, a session cookie and a
|
||||
302 to a same-origin `rd`. A link minted with `confirm` is the `/user/login_link` page
|
||||
instead, which spends it only on a click (`POST` to the same path, answering `{location}`), so
|
||||
a mail scanner opening it does not. `require_login_type` on the mint refuses (409) an account whose
|
||||
`GET /auth/login_link/{token}` unauthenticated): single-use, ≤15 min, a session cookie and a
|
||||
302 to a same-origin `rd`. `require_login_type` on the mint refuses (409) an account whose
|
||||
`login_type` has moved on — the way a caller re-entering an account it created stops being
|
||||
able to once the owner has a password or a provider.
|
||||
- **Pre-approved trial offer** (`cloud_trial_offer`, cloud-only routes under
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user