From e920101107256589bb5aee09fa8f04f5bd9707e4 Mon Sep 17 00:00:00 2001 From: dieriba Date: Thu, 27 Mar 2025 09:43:42 +0100 Subject: [PATCH] feat: api key/basic/hmac auth for http triggers (#5476) * feat: add webhook configuration in front and start handling logic in back * feat: add generic implementation * update: add stripe provider working on auth * merge main * handling new webhook, add generic implem * feat: add auth method and api key and handling custom authentication * nits * added generic implem for webhook provider * feat: added resource webhook_auth to handle http route auth, refactored backend code * update sqlx * fix: zoom and twitch challenge request event, call verify function * removing webhook name to make it more general, properly added custom auth option alongside windmill_auth option * update .sqlx * update deply to for http routes * update .sqlx * add different auth method for http routes * add custom script method and custom signature fixed bug with webhook auth * update auth options * update .sqlx * add new auth method * update .sqlx * refacto function and fix frontend bug * fix typo, update .sqlx and handle pototential error * nits * nits * add script template flow * Update frontend/src/lib/components/triggers/http/utils.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * nits * update: remove wrong trigger type and use right icon for sqs trigger * nits * update: .sqlx * update: reunite flow and script logic for script placeholder value replacement * Update backend/windmill-api/src/http_trigger_auth.rs Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * delete vite config * remove darks mode observer * update: add new option to capture panel for http route * nits * revert * nits * typo --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: HugoCasa --- ...3316248719dfa8a6c9e1d15a7931638e86db.json} | 30 +- ...c913a19eef8678dc7890c2f0e1ef8758ec43b.json | 58 ++ ...92079c7274ca0827dc2a26b29394c12b3f7fc.json | 42 - ...aca0ea457da8252394eeeead0897d184f6da.json} | 46 +- ...55a4727195125cd49a2f92f89bcdf74dc364.json} | 52 +- ...64bd112977c995bf266680c54f58ad57fd4b6.json | 38 - ...05d0588a9ec8bbea9db10e95783e27e35bc12.json | 57 ++ ...f646d6be80e702193f1acdd40dd6033652e4.json} | 30 +- ...f9b717990371d98e0784e96eb31cfdd17885a.json | 56 ++ ...05d394a7cbcf0038c72a78add5c7b02ef5927.json | 2 +- ...89b3abe9d04b86ad50c0f62903a1a79479a60.json | 43 - backend/Cargo.lock | 4 + backend/Cargo.toml | 4 + ...quire_auth_column_of_http_trigger.down.sql | 15 + ...require_auth_column_of_http_trigger.up.sql | 22 + backend/windmill-api/Cargo.toml | 4 +- backend/windmill-api/openapi.yaml | 35 +- backend/windmill-api/src/capture.rs | 53 +- backend/windmill-api/src/http_trigger_auth.rs | 747 ++++++++++++++++++ backend/windmill-api/src/http_triggers.rs | 242 ++++-- backend/windmill-api/src/lib.rs | 2 + backend/windmill-api/src/resources.rs | 1 + backend/windmill-api/src/utils.rs | 11 +- backend/windmill-api/src/workspaces_export.rs | 3 +- backend/windmill-common/src/error.rs | 7 +- .../components/common/button/Button.svelte | 2 +- .../components/triggers/CaptureWrapper.svelte | 3 + .../triggers/TriggersWrapper.svelte | 3 + .../http/RouteBodyTransformerOption.svelte | 41 + .../http/RouteEditorConfigSection.svelte | 13 +- .../triggers/http/RouteEditorInner.svelte | 325 ++++++-- .../src/lib/components/triggers/http/utils.ts | 32 +- frontend/src/lib/hub.ts | 23 + frontend/src/lib/utils_deployable.ts | 14 +- .../(root)/(logged)/flows/add/+page.svelte | 7 + .../(root)/(logged)/scripts/add/+page.svelte | 3 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 7 +- 37 files changed, 1726 insertions(+), 351 deletions(-) rename backend/.sqlx/{query-157090e958a907a09554c2491cabf9a8108c436cba1d2b770e9104d1c8590bcb.json => query-144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db.json} (73%) create mode 100644 backend/.sqlx/query-187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b.json delete mode 100644 backend/.sqlx/query-22888025706d457da65207ea9d592079c7274ca0827dc2a26b29394c12b3f7fc.json rename backend/.sqlx/{query-54798bea6f75ab6d3c680b1085f0462df32172ad0d67321495c94bf2d59010f5.json => query-4053f0bb30f651ddf2214115748daca0ea457da8252394eeeead0897d184f6da.json} (63%) rename backend/.sqlx/{query-5824982a58edd5bb2666daac49b2a6ff8553b9c196ca64ea2752df8cfe30c30c.json => query-56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364.json} (66%) delete mode 100644 backend/.sqlx/query-5d3401f608b27937285e418f7bd64bd112977c995bf266680c54f58ad57fd4b6.json create mode 100644 backend/.sqlx/query-8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12.json rename backend/.sqlx/{query-6860deb7064e57b9e7646438145dc57cc51c7ea256058daf170a1ec8573ed102.json => query-927149213e0f8ae983652ef80464f646d6be80e702193f1acdd40dd6033652e4.json} (64%) create mode 100644 backend/.sqlx/query-dd06bdc09968add6a7c09f124f1f9b717990371d98e0784e96eb31cfdd17885a.json delete mode 100644 backend/.sqlx/query-fd724083c4ef6ec0f1cede5813c89b3abe9d04b86ad50c0f62903a1a79479a60.json create mode 100644 backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.down.sql create mode 100644 backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.up.sql create mode 100644 backend/windmill-api/src/http_trigger_auth.rs create mode 100644 frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte diff --git a/backend/.sqlx/query-157090e958a907a09554c2491cabf9a8108c436cba1d2b770e9104d1c8590bcb.json b/backend/.sqlx/query-144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db.json similarity index 73% rename from backend/.sqlx/query-157090e958a907a09554c2491cabf9a8108c436cba1d2b770e9104d1c8590bcb.json rename to backend/.sqlx/query-144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db.json index df4a83bdb4..e573bfdab7 100644 --- a/backend/.sqlx/query-157090e958a907a09554c2491cabf9a8108c436cba1d2b770e9104d1c8590bcb.json +++ b/backend/.sqlx/query-144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n requires_auth, \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ", + "query": "\n SELECT \n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n script_path, \n is_flow, \n http_method as \"http_method: _\", \n edited_by, \n email, \n edited_at, \n extra_perms, \n is_async, \n authentication_method as \"authentication_method: _\", \n static_asset_config as \"static_asset_config: _\", \n is_static_website,\n authentication_resource_path,\n wrap_body,\n raw_string\n FROM \n http_trigger\n WHERE \n workspace_id = $1 AND \n path = $2\n ", "describe": { "columns": [ { @@ -83,8 +83,22 @@ }, { "ordinal": 13, - "name": "requires_auth", - "type_info": "Bool" + "name": "authentication_method: _", + "type_info": { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + } }, { "ordinal": 14, @@ -98,11 +112,16 @@ }, { "ordinal": 16, + "name": "authentication_resource_path", + "type_info": "Varchar" + }, + { + "ordinal": 17, "name": "wrap_body", "type_info": "Bool" }, { - "ordinal": 17, + "ordinal": 18, "name": "raw_string", "type_info": "Bool" } @@ -130,9 +149,10 @@ false, true, false, + true, false, false ] }, - "hash": "157090e958a907a09554c2491cabf9a8108c436cba1d2b770e9104d1c8590bcb" + "hash": "144e4eccfd1c1e729e3c864bd5dc3316248719dfa8a6c9e1d15a7931638e86db" } diff --git a/backend/.sqlx/query-187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b.json b/backend/.sqlx/query-187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b.json new file mode 100644 index 0000000000..db7d3ab2ec --- /dev/null +++ b/backend/.sqlx/query-187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7, \n path = $8, \n is_flow = $9, \n http_method = $10, \n static_asset_config = $11, \n edited_by = $12, \n email = $13, \n is_async = $14, \n authentication_method = $15, \n edited_at = now(), \n is_static_website = $16\n WHERE \n workspace_id = $17 AND \n path = $18\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Bool", + "Bool", + "Bool", + "Varchar", + "Varchar", + "Varchar", + "Bool", + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + "Bool", + { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + }, + "Bool", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "187e8f85a71dea958e89fdfdf96c913a19eef8678dc7890c2f0e1ef8758ec43b" +} diff --git a/backend/.sqlx/query-22888025706d457da65207ea9d592079c7274ca0827dc2a26b29394c12b3f7fc.json b/backend/.sqlx/query-22888025706d457da65207ea9d592079c7274ca0827dc2a26b29394c12b3f7fc.json deleted file mode 100644 index f4e752cc71..0000000000 --- a/backend/.sqlx/query-22888025706d457da65207ea9d592079c7274ca0827dc2a26b29394c12b3f7fc.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n wrap_body,\n raw_string,\n script_path, \n is_flow, \n is_async, \n requires_auth, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, now(), $16\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Bool", - "Bool", - "Bool", - "Varchar", - "Bool", - "Bool", - "Bool", - { - "Custom": { - "name": "http_method", - "kind": { - "Enum": [ - "get", - "post", - "put", - "delete", - "patch" - ] - } - } - }, - "Jsonb", - "Varchar", - "Varchar", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "22888025706d457da65207ea9d592079c7274ca0827dc2a26b29394c12b3f7fc" -} diff --git a/backend/.sqlx/query-54798bea6f75ab6d3c680b1085f0462df32172ad0d67321495c94bf2d59010f5.json b/backend/.sqlx/query-4053f0bb30f651ddf2214115748daca0ea457da8252394eeeead0897d184f6da.json similarity index 63% rename from backend/.sqlx/query-54798bea6f75ab6d3c680b1085f0462df32172ad0d67321495c94bf2d59010f5.json rename to backend/.sqlx/query-4053f0bb30f651ddf2214115748daca0ea457da8252394eeeead0897d184f6da.json index cafdebb2c3..ff3968567c 100644 --- a/backend/.sqlx/query-54798bea6f75ab6d3c680b1085f0462df32172ad0d67321495c94bf2d59010f5.json +++ b/backend/.sqlx/query-4053f0bb30f651ddf2214115748daca0ea457da8252394eeeead0897d184f6da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n path, \n script_path, \n is_flow, \n route_path, \n workspace_id, \n is_async, \n requires_auth, \n edited_by, \n email, \n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n workspaced_route,\n is_static_website\n FROM \n http_trigger \n WHERE \n http_method = $1\n ", + "query": "\n SELECT \n path, \n script_path, \n is_flow, \n route_path, \n authentication_resource_path,\n workspace_id, \n is_async, \n authentication_method AS \"authentication_method: _\", \n edited_by, \n email, \n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n workspaced_route,\n is_static_website\n FROM \n http_trigger \n WHERE \n http_method = $1\n ", "describe": { "columns": [ { @@ -25,51 +25,70 @@ }, { "ordinal": 4, - "name": "workspace_id", + "name": "authentication_resource_path", "type_info": "Varchar" }, { "ordinal": 5, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, "name": "is_async", "type_info": "Bool" }, { - "ordinal": 6, - "name": "requires_auth", - "type_info": "Bool" + "ordinal": 7, + "name": "authentication_method: _", + "type_info": { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + } }, { - "ordinal": 7, + "ordinal": 8, "name": "edited_by", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "email", "type_info": "Varchar" }, { - "ordinal": 9, + "ordinal": 10, "name": "static_asset_config: _", "type_info": "Jsonb" }, { - "ordinal": 10, + "ordinal": 11, "name": "wrap_body", "type_info": "Bool" }, { - "ordinal": 11, + "ordinal": 12, "name": "raw_string", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 13, "name": "workspaced_route", "type_info": "Bool" }, { - "ordinal": 13, + "ordinal": 14, "name": "is_static_website", "type_info": "Bool" } @@ -97,6 +116,7 @@ false, false, false, + true, false, false, false, @@ -109,5 +129,5 @@ false ] }, - "hash": "54798bea6f75ab6d3c680b1085f0462df32172ad0d67321495c94bf2d59010f5" + "hash": "4053f0bb30f651ddf2214115748daca0ea457da8252394eeeead0897d184f6da" } diff --git a/backend/.sqlx/query-5824982a58edd5bb2666daac49b2a6ff8553b9c196ca64ea2752df8cfe30c30c.json b/backend/.sqlx/query-56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364.json similarity index 66% rename from backend/.sqlx/query-5824982a58edd5bb2666daac49b2a6ff8553b9c196ca64ea2752df8cfe30c30c.json rename to backend/.sqlx/query-56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364.json index c54aa8f20d..d547567d28 100644 --- a/backend/.sqlx/query-5824982a58edd5bb2666daac49b2a6ff8553b9c196ca64ea2752df8cfe30c30c.json +++ b/backend/.sqlx/query-56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n script_path, \n is_flow, \n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n requires_auth, \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ", + "query": "\n SELECT \n workspace_id, \n workspaced_route,\n path, \n route_path, \n route_path_key, \n authentication_resource_path,\n script_path, \n is_flow, \n edited_by, \n edited_at, \n email, \n extra_perms, \n is_async, \n authentication_method AS \"authentication_method: _\", \n http_method AS \"http_method: _\", \n static_asset_config AS \"static_asset_config: _\", \n is_static_website,\n wrap_body,\n raw_string\n FROM http_trigger\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { @@ -30,46 +30,65 @@ }, { "ordinal": 5, - "name": "script_path", + "name": "authentication_resource_path", "type_info": "Varchar" }, { "ordinal": 6, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 7, "name": "is_flow", "type_info": "Bool" }, { - "ordinal": 7, + "ordinal": 8, "name": "edited_by", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "edited_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "email", "type_info": "Varchar" }, { - "ordinal": 10, + "ordinal": 11, "name": "extra_perms", "type_info": "Jsonb" }, { - "ordinal": 11, + "ordinal": 12, "name": "is_async", "type_info": "Bool" }, { - "ordinal": 12, - "name": "requires_auth", - "type_info": "Bool" + "ordinal": 13, + "name": "authentication_method: _", + "type_info": { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + } }, { - "ordinal": 13, + "ordinal": 14, "name": "http_method: _", "type_info": { "Custom": { @@ -87,22 +106,22 @@ } }, { - "ordinal": 14, + "ordinal": 15, "name": "static_asset_config: _", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 16, "name": "is_static_website", "type_info": "Bool" }, { - "ordinal": 16, + "ordinal": 17, "name": "wrap_body", "type_info": "Bool" }, { - "ordinal": 17, + "ordinal": 18, "name": "raw_string", "type_info": "Bool" } @@ -118,6 +137,7 @@ false, false, false, + true, false, false, false, @@ -133,5 +153,5 @@ false ] }, - "hash": "5824982a58edd5bb2666daac49b2a6ff8553b9c196ca64ea2752df8cfe30c30c" + "hash": "56c2522a12f91515e38290e4680a55a4727195125cd49a2f92f89bcdf74dc364" } diff --git a/backend/.sqlx/query-5d3401f608b27937285e418f7bd64bd112977c995bf266680c54f58ad57fd4b6.json b/backend/.sqlx/query-5d3401f608b27937285e418f7bd64bd112977c995bf266680c54f58ad57fd4b6.json deleted file mode 100644 index b962770bd9..0000000000 --- a/backend/.sqlx/query-5d3401f608b27937285e418f7bd64bd112977c995bf266680c54f58ad57fd4b6.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE \n http_trigger \n SET \n script_path = $1, \n path = $2, \n is_flow = $3, \n http_method = $4,\n static_asset_config = $5, \n edited_by = $6, \n email = $7, \n is_async = $8, \n requires_auth = $9, \n edited_at = now(), \n is_static_website = $10\n WHERE \n workspace_id = $11 AND \n path = $12\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - { - "Custom": { - "name": "http_method", - "kind": { - "Enum": [ - "get", - "post", - "put", - "delete", - "patch" - ] - } - } - }, - "Jsonb", - "Varchar", - "Varchar", - "Bool", - "Bool", - "Bool", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "5d3401f608b27937285e418f7bd64bd112977c995bf266680c54f58ad57fd4b6" -} diff --git a/backend/.sqlx/query-8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12.json b/backend/.sqlx/query-8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12.json new file mode 100644 index 0000000000..961a55d8b1 --- /dev/null +++ b/backend/.sqlx/query-8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO http_trigger (\n workspace_id, \n path, \n route_path, \n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path, \n is_flow, \n is_async, \n authentication_method, \n http_method, \n static_asset_config, \n edited_by, \n email, \n edited_at, \n is_static_website\n ) \n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Bool", + "Bool", + "Varchar", + "Bool", + "Bool", + { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + }, + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "8c30e91c2486f7511563621e7e805d0588a9ec8bbea9db10e95783e27e35bc12" +} diff --git a/backend/.sqlx/query-6860deb7064e57b9e7646438145dc57cc51c7ea256058daf170a1ec8573ed102.json b/backend/.sqlx/query-927149213e0f8ae983652ef80464f646d6be80e702193f1acdd40dd6033652e4.json similarity index 64% rename from backend/.sqlx/query-6860deb7064e57b9e7646438145dc57cc51c7ea256058daf170a1ec8573ed102.json rename to backend/.sqlx/query-927149213e0f8ae983652ef80464f646d6be80e702193f1acdd40dd6033652e4.json index be5ab7a673..e4682cfedf 100644 --- a/backend/.sqlx/query-6860deb7064e57b9e7646438145dc57cc51c7ea256058daf170a1ec8573ed102.json +++ b/backend/.sqlx/query-927149213e0f8ae983652ef80464f646d6be80e702193f1acdd40dd6033652e4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n path, \n script_path, \n is_flow, \n route_path, \n workspace_id, \n is_async, \n requires_auth, \n edited_by, \n email,\n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n workspaced_route,\n is_static_website \n FROM \n http_trigger \n WHERE \n workspace_id = $1 AND \n http_method = $2\n ", + "query": "\n SELECT \n path, \n script_path, \n is_flow, \n route_path, \n workspace_id, \n is_async, \n authentication_method AS \"authentication_method: _\", \n edited_by, \n email,\n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n workspaced_route,\n is_static_website,\n authentication_resource_path\n FROM \n http_trigger \n WHERE \n workspace_id = $1 AND \n http_method = $2\n ", "describe": { "columns": [ { @@ -35,8 +35,22 @@ }, { "ordinal": 6, - "name": "requires_auth", - "type_info": "Bool" + "name": "authentication_method: _", + "type_info": { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + } }, { "ordinal": 7, @@ -72,6 +86,11 @@ "ordinal": 13, "name": "is_static_website", "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "authentication_resource_path", + "type_info": "Varchar" } ], "parameters": { @@ -107,8 +126,9 @@ false, false, false, - false + false, + true ] }, - "hash": "6860deb7064e57b9e7646438145dc57cc51c7ea256058daf170a1ec8573ed102" + "hash": "927149213e0f8ae983652ef80464f646d6be80e702193f1acdd40dd6033652e4" } diff --git a/backend/.sqlx/query-dd06bdc09968add6a7c09f124f1f9b717990371d98e0784e96eb31cfdd17885a.json b/backend/.sqlx/query-dd06bdc09968add6a7c09f124f1f9b717990371d98e0784e96eb31cfdd17885a.json new file mode 100644 index 0000000000..0d2df00658 --- /dev/null +++ b/backend/.sqlx/query-dd06bdc09968add6a7c09f124f1f9b717990371d98e0784e96eb31cfdd17885a.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE \n http_trigger \n SET \n workspaced_route = $1,\n wrap_body = $2,\n raw_string = $3,\n authentication_resource_path = $4,\n script_path = $5, \n path = $6, \n is_flow = $7, \n http_method = $8, \n static_asset_config = $9, \n edited_by = $10, \n email = $11, \n is_async = $12, \n authentication_method = $13, \n edited_at = now(), \n is_static_website = $14\n WHERE \n workspace_id = $15 AND \n path = $16\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Bool", + "Bool", + "Varchar", + "Varchar", + "Varchar", + "Bool", + { + "Custom": { + "name": "http_method", + "kind": { + "Enum": [ + "get", + "post", + "put", + "delete", + "patch" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + "Bool", + { + "Custom": { + "name": "authentication_method", + "kind": { + "Enum": [ + "none", + "windmill", + "api_key", + "basic_http", + "custom_script", + "signature" + ] + } + } + }, + "Bool", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dd06bdc09968add6a7c09f124f1f9b717990371d98e0784e96eb31cfdd17885a" +} diff --git a/backend/.sqlx/query-ddf2eccb78a310ed00c7d8b9c3f05d394a7cbcf0038c72a78add5c7b02ef5927.json b/backend/.sqlx/query-ddf2eccb78a310ed00c7d8b9c3f05d394a7cbcf0038c72a78add5c7b02ef5927.json index c2dfed73a2..5bfff47576 100644 --- a/backend/.sqlx/query-ddf2eccb78a310ed00c7d8b9c3f05d394a7cbcf0038c72a78add5c7b02ef5927.json +++ b/backend/.sqlx/query-ddf2eccb78a310ed00c7d8b9c3f05d394a7cbcf0038c72a78add5c7b02ef5927.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "ddf2eccb78a310ed00c7d8b9c3f05d394a7cbcf0038c72a78add5c7b02ef5927" diff --git a/backend/.sqlx/query-fd724083c4ef6ec0f1cede5813c89b3abe9d04b86ad50c0f62903a1a79479a60.json b/backend/.sqlx/query-fd724083c4ef6ec0f1cede5813c89b3abe9d04b86ad50c0f62903a1a79479a60.json deleted file mode 100644 index a24ed36971..0000000000 --- a/backend/.sqlx/query-fd724083c4ef6ec0f1cede5813c89b3abe9d04b86ad50c0f62903a1a79479a60.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE \n http_trigger \n SET \n route_path = $1, \n route_path_key = $2, \n workspaced_route = $3, \n wrap_body = $4,\n raw_string = $5,\n script_path = $6, \n path = $7, \n is_flow = $8, \n http_method = $9, \n static_asset_config = $10, \n edited_by = $11, \n email = $12, \n is_async = $13, \n requires_auth = $14, \n edited_at = now(), \n is_static_website = $15\n WHERE \n workspace_id = $16 AND \n path = $17\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - "Bool", - "Bool", - "Varchar", - "Varchar", - "Bool", - { - "Custom": { - "name": "http_method", - "kind": { - "Enum": [ - "get", - "post", - "put", - "delete", - "patch" - ] - } - } - }, - "Jsonb", - "Varchar", - "Varchar", - "Bool", - "Bool", - "Bool", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "fd724083c4ef6ec0f1cede5813c89b3abe9d04b86ad50c0f62903a1a79479a60" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 28bf1a4088..c06a2031ab 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13652,6 +13652,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "constant_time_eq", "deno_core", "dotenv", "futures", @@ -13668,6 +13669,7 @@ dependencies = [ "rustls 0.23.25", "serde", "serde_json", + "sha1", "sha2 0.10.8", "sqlx", "tikv-jemalloc-ctl", @@ -13712,6 +13714,7 @@ dependencies = [ "chrono", "chrono-tz 0.10.1", "const_format", + "constant_time_eq", "cookie 0.17.0", "cron", "datafusion", @@ -13752,6 +13755,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "sha1", "sha2 0.10.8", "sql-builder", "sqlx", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b3f4758aa6..f45590e562 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -118,6 +118,8 @@ serde_json.workspace = true serde.workspace = true deno_core = { workspace = true, optional = true } object_store = { workspace = true, optional = true } +sha1 = { workspace = true, optional = true } +constant_time_eq = { workspace = true, optional = true } quote.workspace = true memchr.workspace = true v8 = { workspace = true, optional = true } @@ -248,6 +250,7 @@ base64 = "^0" base32 = "^0" hmac = "0.12.1" sha2 = "0.10.6" +sha1 = "0.10.6" sqlx = { version = "0.8.0", features = [ "macros", "migrate", @@ -266,6 +269,7 @@ futures-core = "^0" lazy_static = "1.4.0" serde_derive = "1.0.147" const_format = { version = "0.2", features = ["rust_1_64", "rust_1_51"] } +constant_time_eq = "0.3.1" dyn-iter = "0.2.0" rsa = "^0" async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] } diff --git a/backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.down.sql b/backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.down.sql new file mode 100644 index 0000000000..f34e8407b8 --- /dev/null +++ b/backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.down.sql @@ -0,0 +1,15 @@ +ALTER TABLE http_trigger + DROP COLUMN authentication_resource_path, + ALTER COLUMN authentication_method DROP DEFAULT, + ALTER COLUMN authentication_method TYPE boolean + USING CASE + WHEN authentication_method = 'windmill'::AUTHENTICATION_METHOD THEN true + ELSE false + END, + ALTER COLUMN authentication_method SET NOT NULL, + ALTER COLUMN authentication_method SET DEFAULT false; + +ALTER TABLE http_trigger + RENAME COLUMN authentication_method TO requires_auth; + +DROP TYPE AUTHENTICATION_METHOD; \ No newline at end of file diff --git a/backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.up.sql b/backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.up.sql new file mode 100644 index 0000000000..713e1b1fe3 --- /dev/null +++ b/backend/migrations/20250319234432_rename_require_auth_column_of_http_trigger.up.sql @@ -0,0 +1,22 @@ +CREATE TYPE AUTHENTICATION_METHOD AS ENUM ( + 'none', + 'windmill', + 'api_key', + 'basic_http', + 'custom_script', + 'signature' +); + +ALTER TABLE http_trigger + RENAME COLUMN requires_auth TO authentication_method; + +ALTER TABLE http_trigger + ADD COLUMN authentication_resource_path VARCHAR(255) DEFAULT NULL, + ALTER COLUMN authentication_method DROP DEFAULT, + ALTER COLUMN authentication_method TYPE AUTHENTICATION_METHOD + USING CASE + WHEN authentication_method = true THEN 'windmill'::AUTHENTICATION_METHOD + ELSE 'none'::AUTHENTICATION_METHOD + END, + ALTER COLUMN authentication_method SET NOT NULL, + ALTER COLUMN authentication_method SET DEFAULT 'none'::AUTHENTICATION_METHOD; diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index d7e3eac0a2..05025e7f8e 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -26,7 +26,7 @@ smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp"] license = ["dep:rsa"] zip = ["dep:async_zip"] oauth2 = ["dep:async-oauth2"] -http_trigger = ["dep:matchit"] +http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_eq"] static_frontend = ["dep:rust-embed"] postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"] mqtt_trigger = ["dep:thiserror", "dep:rumqttc"] @@ -85,6 +85,8 @@ tokio-tar.workspace = true hmac.workspace = true cookie.workspace = true sha2.workspace = true +sha1 = { workspace = true, optional = true } +constant_time_eq = { workspace = true, optional = true } urlencoding.workspace = true lazy_static.workspace = true prometheus = { workspace = true, optional = true } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 96756fc9fd..ba7e841e8d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -13939,6 +13939,17 @@ components: - edited_at - is_flow + + AuthenticationMethod: + type: string + enum: + - none + - windmill + - api_key + - basic_http + - custom_script + - signature + HttpTrigger: allOf: - $ref: "#/components/schemas/TriggerExtraProperty" @@ -13965,10 +13976,12 @@ components: - put - delete - patch + authentication_resource_path: + type: string is_async: type: boolean - requires_auth: - type: boolean + authentication_method: + $ref: "#/components/schemas/AuthenticationMethod" is_static_website: type: boolean workspaced_route: @@ -13981,7 +13994,7 @@ components: required: - route_path - is_async - - requires_auth + - authentication_method - http_method - is_static_website - workspaced_route @@ -14020,10 +14033,12 @@ components: - put - delete - patch + authentication_resource_path: + type: string is_async: type: boolean - requires_auth: - type: boolean + authentication_method: + $ref: "#/components/schemas/AuthenticationMethod" is_static_website: type: boolean wrap_body: @@ -14037,7 +14052,7 @@ components: - route_path - is_flow - is_async - - requires_auth + - authentication_method - http_method - is_static_website @@ -14063,6 +14078,8 @@ components: type: string required: - s3 + authentication_resource_path: + type: string is_flow: type: boolean http_method: @@ -14075,8 +14092,8 @@ components: - patch is_async: type: boolean - requires_auth: - type: boolean + authentication_method: + $ref: "#/components/schemas/AuthenticationMethod" is_static_website: type: boolean wrap_body: @@ -14089,7 +14106,7 @@ components: - is_flow - kind - is_async - - requires_auth + - authentication_method - http_method - is_static_website diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 06e4f4ad4d..dfe0ec6005 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -20,6 +20,10 @@ use crate::postgres_triggers::{ generate_random_string, get_database_connection, PublicationData, }; #[cfg(feature = "http_trigger")] +use axum::extract::Request; +#[cfg(feature = "http_trigger")] +use axum::response::IntoResponse; +#[cfg(feature = "http_trigger")] use http::HeaderMap; #[cfg(feature = "postgres_trigger")] use itertools::Itertools; @@ -99,6 +103,8 @@ pub fn workspaced_unauthed_service() -> Router { struct HttpTriggerConfig { route_path: String, http_method: HttpMethod, + raw_string: Option, + wrap_body: Option, } #[cfg(all(feature = "enterprise", feature = "kafka"))] @@ -643,8 +649,12 @@ async fn http_payload( Query(query): Query>, method: http::Method, headers: HeaderMap, - args: WebhookArgs, -) -> Result { + request: Request, +) -> std::result::Result { + use axum::response::Response; + + use crate::args::try_from_request_body; + let route_path = route_path.to_path(); let path = path.replace(".", "/"); @@ -656,16 +666,32 @@ async fn http_payload( matches!(kind, RunnableKind::Flow), &TriggerKind::Http, ) - .await?; + .await + .map_err(|e| e.into_response())?; - let authed = fetch_api_authed(owner.clone(), email, &w_id, &db, None).await?; - let args = args.to_push_args_owned(&authed, &db, &w_id).await?; + let args = try_from_request_body( + request, + &db, + http_trigger_config.raw_string, + http_trigger_config.wrap_body, + ) + .await + .map_err(|e| e.into_response())?; + + let authed = fetch_api_authed(owner.clone(), email, &w_id, &db, None) + .await + .map_err(|e| e.into_response())?; + let mut args = args + .to_push_args_owned(&authed, &db, &w_id) + .await + .map_err(|e| e.into_response())?; let mut router = matchit::Router::new(); router.insert(&http_trigger_config.route_path, ()).ok(); let match_ = router.at(route_path).ok(); - let match_ = not_found_if_none(match_, "capture http trigger", &route_path)?; + let match_ = not_found_if_none(match_, "capture http trigger", &route_path) + .map_err(|e| e.into_response())?; let matchit::Match { params, .. } = match_; @@ -674,7 +700,9 @@ async fn http_payload( .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); - let extra: HashMap> = HashMap::from_iter(vec![( + let extra = args.extra.get_or_insert_with(HashMap::new); + + extra.insert( "wm_trigger".to_string(), build_http_trigger_extra( &http_trigger_config.route_path, @@ -685,8 +713,10 @@ async fn http_payload( &headers, ) .await, - )]); + ); + let extra = Some(to_raw_value(&extra)); + args.extra = None; insert_capture_payload( &db, &w_id, @@ -694,10 +724,11 @@ async fn http_payload( matches!(kind, RunnableKind::Flow), &TriggerKind::Http, args, - Some(to_raw_value(&extra)), + extra, &owner, ) - .await?; + .await + .map_err(|e| e.into_response())?; - Ok(StatusCode::NO_CONTENT) + Ok::<_, Response>(StatusCode::NO_CONTENT) } diff --git a/backend/windmill-api/src/http_trigger_auth.rs b/backend/windmill-api/src/http_trigger_auth.rs new file mode 100644 index 0000000000..ee60f70efa --- /dev/null +++ b/backend/windmill-api/src/http_trigger_auth.rs @@ -0,0 +1,747 @@ +use axum::response::{IntoResponse, Response}; +use base64::{ + prelude::{BASE64_STANDARD, BASE64_URL_SAFE}, + Engine, +}; +use hmac::{Hmac, Mac}; +use http::{header, HeaderMap, HeaderValue, StatusCode}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha1::Sha1; +use sha2::{Sha256, Sha512}; +use std::{borrow::Cow, collections::HashMap}; + +pub type HmacSha256 = Hmac; +pub type HmacSha512 = Hmac; +pub type HmacSha1 = Hmac; + +mod github { + use super::*; + pub struct Github; + + impl WebhookHandler for Github { + fn handle_challenge_request<'header>( + &self, + _: &'header HeaderMap, + _: &SignatureConfigData, + _: &str, + ) -> Result, AuthenticationError> { + Ok(None) + } + + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError> + { + let github_secret_header = headers.try_get_webhook_header("X-Hub-Signature-256")?; + + let authentication_data = SignatureAuthenticationData::new( + Cow::Borrowed(raw_payload), + github_secret_header, + Some("sha256="), + SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), + ); + + Ok(authentication_data) + } + } +} + +mod slack { + use super::*; + pub struct Slack; + + impl WebhookHandler for Slack { + fn handle_challenge_request<'header>( + &self, + _: &'header HeaderMap, + _: &SignatureConfigData, + _: &str, + ) -> Result, AuthenticationError> { + Ok(None) + } + + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError> + { + let slack_secret_signature = headers.try_get_webhook_header("X-Slack-Signature")?; + let slack_timestamp_header = + headers.try_get_webhook_header("X-Slack-Request-Timestamp")?; + let signed_payload = format!("v0:{}:{}", slack_timestamp_header, raw_payload); + + Ok(SignatureAuthenticationData::new( + Cow::Owned(signed_payload), + slack_secret_signature, + Some("v0="), + SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), + )) + } + } +} + +mod stripe { + use super::*; + + pub struct Stripe; + + impl WebhookHandler for Stripe { + fn handle_challenge_request<'header>( + &self, + _: &'header HeaderMap, + _: &SignatureConfigData, + _: &str, + ) -> Result, AuthenticationError> { + Ok(None) + } + + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError> + { + let stripe_signature_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?; + + let stripe_signature = parse_signature(stripe_signature_header, (",", "=")); + + let timestamp = *stripe_signature + .get("t") + .ok_or(AuthenticationError::InvalidTimestamp)?; + let v1 = *stripe_signature + .get("v1") + .ok_or(AuthenticationError::InvalidSignature)?; + + let signed_payload = format!("{}.{}", timestamp, raw_payload); + + Ok(SignatureAuthenticationData::new( + Cow::Owned(signed_payload), + v1, + None, + SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), + )) + } + } +} + +mod tiktok { + use super::*; + + pub struct TikTok; + + impl WebhookHandler for TikTok { + fn handle_challenge_request<'header>( + &self, + _: &'header HeaderMap, + _: &SignatureConfigData, + _: &str, + ) -> Result, AuthenticationError> { + Ok(None) + } + + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError> + { + let tiktok_secret_signature = headers.try_get_webhook_header("TikTok-Signature")?; + + let stripe_signature = parse_signature(tiktok_secret_signature, (",", "=")); + + let timestamp = *stripe_signature + .get("t") + .ok_or(AuthenticationError::InvalidTimestamp)?; + let s = *stripe_signature + .get("s") + .ok_or(AuthenticationError::InvalidSignature)?; + + let signed_payload = format!("{}.{}", timestamp, raw_payload); + + Ok(SignatureAuthenticationData::new( + Cow::Owned(signed_payload), + s, + None, + SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), + )) + } + } +} + +mod twitch { + use super::*; + use http::header; + use serde_json::value::RawValue; + #[derive(Debug, Deserialize)] + struct TwitchCrcBody { + challenge: String, + #[allow(unused)] + subscription: Box, + } + + pub struct Twitch; + + impl WebhookHandler for Twitch { + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError> + { + let twitch_secret_signature = + headers.try_get_webhook_header("Twitch-Eventsub-Message-Signature")?; + let twitch_message_id_header = + headers.try_get_webhook_header("Twitch-Eventsub-Message-Id")?; + let twitch_timestamp_header = + headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?; + + let message = format!( + "{}{}{}", + twitch_message_id_header, twitch_timestamp_header, raw_payload + ); + + Ok(SignatureAuthenticationData::new( + Cow::Owned(message), + twitch_secret_signature, + Some("sha256="), + SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), + )) + } + + fn handle_challenge_request<'header>( + &self, + headers: &'header HeaderMap, + signature_config_data: &SignatureConfigData, + raw_payload: &str, + ) -> Result, AuthenticationError> { + let authentication_data = self.get_hmac_authentication_data(headers, raw_payload)?; + verify_hmac_signature(authentication_data, &signature_config_data.secret_key)?; + + let twitch_eventsub_message_type = + headers.try_get_webhook_header("Twitch-Eventsub-Message-Type")?; + + if twitch_eventsub_message_type != "webhook_callback_verification" { + return Ok(None); + } + let twitch_crc_body = + serde_json::from_str::(raw_payload).map_err(|e| { + AuthenticationError::InvalidChallengeResponse(format!( + "Twitch :{}", + e.to_string() + )) + })?; + + let response = ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain")], + twitch_crc_body.challenge.to_string(), + ); + + Ok(Some(response.into_response())) + } + } +} + +mod zoom { + use axum::Json; + + use super::*; + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "snake_case")] + struct ZoomPayload { + plain_token: String, + } + + #[derive(Debug, Deserialize)] + #[allow(unused)] + struct ZoomChallengeResponse { + payload: ZoomPayload, + event_ts: u64, + event: String, + } + + pub struct Zoom; + + impl WebhookHandler for Zoom { + fn handle_challenge_request<'header>( + &self, + _: &'header HeaderMap, + signature_config_data: &SignatureConfigData, + raw_payload: &str, + ) -> Result, AuthenticationError> { + let Ok(zoom_request_body) = serde_json::from_str::(raw_payload) + else { + return Ok(None); + }; + + if zoom_request_body.event != "endpoint.url_validation" { + return Ok(None); + } + + let hmac_signature = calculate_hmac_signature( + HmacAlgorithm::Sha256, + &signature_config_data.secret_key, + &zoom_request_body.payload.plain_token, + ); + + let encoded_hmac_signature = encode_hmac_signature(Encoding::Hex, &hmac_signature); + + let response = ( + StatusCode::OK, + Json(json!({ + "plainToken": zoom_request_body.payload.plain_token, + "encryptedToken": encoded_hmac_signature + })), + ); + + Ok(Some(response.into_response())) + } + + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError> + { + let zoom_signature_header = headers.try_get_webhook_header("x-zm-signature")?; + let zoom_timestamp_header = headers.try_get_webhook_header("x-zm-request-timestamp")?; + + let message = format!("v0:{}:{}", zoom_timestamp_header, raw_payload); + + Ok(SignatureAuthenticationData::new( + Cow::Owned(message), + zoom_signature_header, + Some("v0="), + SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), + )) + } + } +} + +use constant_time_eq::constant_time_eq; +use github::Github; +use slack::Slack; +use stripe::Stripe; +use tiktok::TikTok; +use twitch::Twitch; +use zoom::Zoom; + +#[derive(Debug)] +pub struct SignatureAuthenticationDetails { + pub algorithm_to_use: HmacAlgorithm, + pub header_key_encoding: Encoding, +} + +impl SignatureAuthenticationDetails { + #[inline] + fn new(algorithm_to_use: HmacAlgorithm, header_key_encoding: Encoding) -> Self { + Self { algorithm_to_use, header_key_encoding } + } +} + +fn parse_signature<'header>( + signature: &'header str, + splitters: (&str, &str), +) -> HashMap<&'header str, &'header str> { + let headers: HashMap<&str, &str> = signature + .split(splitters.0) + .map(|header| { + let mut key_and_value = header.split(splitters.1); + let key = key_and_value.next(); + let value = key_and_value.next(); + (key, value) + }) + .filter_map(|(key, value)| match (key, value) { + (Some(key), Some(value)) => Some((key, value)), + _ => None, + }) + .collect(); + headers +} + +#[derive(Debug)] +pub struct SignatureAuthenticationData<'payload, 'header, 'prefix> { + pub signed_payload: Cow<'payload, str>, + pub header_key_value: &'header str, + pub signature_prefix: Option<&'prefix str>, + pub config: SignatureAuthenticationDetails, +} + +impl<'payload, 'header, 'prefix> SignatureAuthenticationData<'payload, 'header, 'prefix> { + pub fn new( + signed_payload: Cow<'payload, str>, + header_key_value: &'header str, + signature_prefix: Option<&'prefix str>, + config: SignatureAuthenticationDetails, + ) -> Self { + Self { signed_payload, header_key_value, signature_prefix, config } + } +} + +pub trait WebhookHandler { + fn handle_challenge_request<'header>( + &self, + headers: &'header HeaderMap, + signature_config_data: &SignatureConfigData, + raw_payload: &str, + ) -> Result, AuthenticationError>; + + fn get_hmac_authentication_data<'payload, 'header, 'prefix>( + &self, + headers: &'header HeaderMap, + raw_payload: &'payload str, + ) -> Result, AuthenticationError>; +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HmacAlgorithm { + Sha1, + Sha256, + Sha512, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Encoding { + Base64, + Base64Uri, + Hex, +} +#[derive(Debug, Serialize, Deserialize)] +pub struct SignatureAuthenticationMethod { + algorithm: HmacAlgorithm, + encoding: Encoding, + signature_header_name: String, + signature_prefix: Option, +} + +pub struct SignatureConfigData<'config> { + secret_key: &'config str, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SignatureAuthentication { + signature_provider: WebhookType, + secret_key: String, + authentication_config: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BasicAuthAuthentication { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiKeyAuthentication { + api_key_header: String, + api_key_secret: String, +} + +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)] +#[non_exhaustive] +pub enum WebhookType { + Github, + Slack, + Stripe, + TikTok, + Twitch, + Zoom, + Custom, +} + +impl WebhookType { + pub fn get_webhook_handler(&self) -> Option<&'static dyn WebhookHandler> { + let handler: &'static dyn WebhookHandler = match *self { + WebhookType::Github => &Github, + WebhookType::Slack => &Slack, + WebhookType::Stripe => &Stripe, + WebhookType::TikTok => &TikTok, + WebhookType::Twitch => &Twitch, + WebhookType::Zoom => &Zoom, + WebhookType::Custom => return None, + }; + Some(handler) + } +} + +trait TryGetWebhookHeader { + fn try_get_webhook_header<'header>( + &'header self, + header_name: &str, + ) -> Result<&'header str, AuthenticationError>; +} + +impl TryGetWebhookHeader for HeaderMap { + fn try_get_webhook_header<'header>( + &'header self, + header_name: &str, + ) -> Result<&'header str, AuthenticationError> { + let Some(signature_header) = self.get(header_name) else { + return Err(AuthenticationError::MissingHeader(header_name.to_string())); + }; + let Some(signature_header) = signature_header.to_str().ok() else { + return Err(AuthenticationError::InvalidHeader(header_name.to_string())); + }; + + Ok(signature_header) + } +} + +pub fn calculate_hmac_signature(algorithm: HmacAlgorithm, secret: &str, payload: &str) -> Vec { + match algorithm { + HmacAlgorithm::Sha1 => { + let mut mac = + HmacSha1::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); + mac.update(payload.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + HmacAlgorithm::Sha256 => { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .expect("HMAC can take key of any size"); + mac.update(payload.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + HmacAlgorithm::Sha512 => { + let mut mac = HmacSha512::new_from_slice(secret.as_bytes()) + .expect("HMAC can take key of any size"); + mac.update(payload.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + } +} + +pub fn encode_hmac_signature(encoding: Encoding, hmac_signature: &[u8]) -> String { + match encoding { + Encoding::Hex => hex::encode(hmac_signature), + Encoding::Base64 => BASE64_STANDARD.encode(hmac_signature), + Encoding::Base64Uri => BASE64_URL_SAFE.encode(hmac_signature), + } +} + +pub fn verify_hmac_signature( + authentication_data: SignatureAuthenticationData, + webhook_signing_secret: &str, +) -> Result<(), AuthenticationError> { + let hmac_signature = calculate_hmac_signature( + authentication_data.config.algorithm_to_use, + &webhook_signing_secret, + &authentication_data.signed_payload, + ); + + let encoded_signature = encode_hmac_signature( + authentication_data.config.header_key_encoding, + &hmac_signature, + ); + + let final_expected_signature = + if let Some(signature_prefix) = authentication_data.signature_prefix { + format!("{}{}", signature_prefix, encoded_signature) + } else { + encoded_signature + }; + + if !constant_time_eq( + final_expected_signature.as_bytes(), + authentication_data.header_key_value.as_bytes(), + ) { + return Err(AuthenticationError::InvalidSignature); + } + + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthenticationMethod { + Signature(SignatureAuthentication), + BasicAuth(BasicAuthAuthentication), + ApiKey(ApiKeyAuthentication), +} + +impl AuthenticationMethod { + pub fn authenticate_http_request( + &self, + headers: &HeaderMap, + raw_payload: Option<&String>, + ) -> Result, AuthenticationError> { + match self { + AuthenticationMethod::Signature(SignatureAuthentication { + secret_key, + authentication_config, + signature_provider, + }) => { + let raw_payload = raw_payload.ok_or(AuthenticationError::InvalidPayload)?; + let config_data = SignatureConfigData { secret_key: &secret_key }; + let handler = signature_provider.get_webhook_handler(); + let challenge_response = handler + .map(|handler| { + handler.handle_challenge_request(headers, &config_data, raw_payload) + }) + .transpose()? + .flatten(); + + if let Some(challenge_response) = challenge_response { + return Ok(Some(challenge_response)); + } + + let authentication_data = match handler { + Some(handler) => handler.get_hmac_authentication_data(headers, raw_payload)?, + None => { + let authentication_config = authentication_config + .as_ref() + .ok_or(AuthenticationError::InvalidCustomConfig)?; + let signature_header_value = headers + .try_get_webhook_header(&authentication_config.signature_header_name)?; + SignatureAuthenticationData::new( + Cow::Borrowed(raw_payload), + signature_header_value, + authentication_config.signature_prefix.as_deref(), + SignatureAuthenticationDetails::new( + authentication_config.algorithm, + authentication_config.encoding, + ), + ) + } + }; + + verify_hmac_signature(authentication_data, &secret_key)?; + } + AuthenticationMethod::ApiKey(ApiKeyAuthentication { + api_key_header, + api_key_secret, + }) => { + let api_key_to_cmp = headers.try_get_webhook_header(&api_key_header)?; + if api_key_to_cmp != api_key_secret { + return Err(AuthenticationError::InvalidApiKey); + } + } + AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username, password }) => { + let mut credentials_store = headers + .try_get_webhook_header("Authorization") + .map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)? + .split(' '); + + let _ = credentials_store + .next() + .filter(|r#type| *r#type == "Basic") + .ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?; + + let credentials_as_base64 = credentials_store + .next() + .ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?; + + let credentials_from_base64_as_bytes = BASE64_STANDARD + .decode(credentials_as_base64.as_bytes()) + .map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?; + + let credentials_separated_with_colon = + String::from_utf8(credentials_from_base64_as_bytes) + .map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?; + + let credentials = credentials_separated_with_colon.split(':').collect_vec(); + + if credentials.len() != 2 { + return Err(AuthenticationError::UnauthorizedBasicHttpAuth); + } + + if credentials.get(0).unwrap() != username + || credentials.get(1).unwrap() != password + { + return Err(AuthenticationError::UnauthorizedBasicHttpAuth); + } + } + } + + Ok(None) + } +} + +#[derive(thiserror::Error, Debug)] +#[allow(unused)] +pub enum AuthenticationError { + #[error("failed to parse timestamp")] + InvalidTimestamp, + + #[error("invalid secret")] + InvalidSecret(#[from] base64::DecodeError), + + #[error("invalid header `{0}`")] + InvalidHeader(String), + + #[error("signature timestamp too old")] + TimestampTooOldError, + + #[error("signature timestamp too far in future")] + FutureTimestampError, + + #[error("missing header {0}")] + MissingHeader(String), + + #[error("signature invalid")] + InvalidSignature, + + #[error("payload invalid")] + InvalidPayload, + + #[error("invalid custom config")] + InvalidCustomConfig, + + #[error("invalid auth header: {0}")] + InvalidAuthHeader(String), + + #[error("invalid api key")] + InvalidApiKey, + + #[error("invalid challenge response: {0}")] + InvalidChallengeResponse(String), + + #[error("")] + UnauthorizedBasicHttpAuth, +} + +impl IntoResponse for AuthenticationError { + fn into_response(self) -> Response { + let (status, error_message) = match &self { + AuthenticationError::InvalidTimestamp + | AuthenticationError::InvalidPayload + | AuthenticationError::InvalidHeader(_) + | AuthenticationError::MissingHeader(_) + | AuthenticationError::TimestampTooOldError + | AuthenticationError::FutureTimestampError + | AuthenticationError::InvalidCustomConfig + | AuthenticationError::InvalidChallengeResponse(_) => { + (StatusCode::BAD_REQUEST, self.to_string()) + } + + AuthenticationError::InvalidSecret(_) + | AuthenticationError::InvalidSignature + | AuthenticationError::InvalidAuthHeader(_) => { + (StatusCode::UNAUTHORIZED, self.to_string()) + } + AuthenticationError::UnauthorizedBasicHttpAuth => { + return ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, r#"Basic realm="Restricted Area""#)], + "Unauthorized", + ) + .into_response() + } + AuthenticationError::InvalidApiKey => (StatusCode::FORBIDDEN, self.to_string()), + }; + + let body = json!({ "error": error_message }); + + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + + (status, headers, body.to_string()).into_response() + } +} diff --git a/backend/windmill-api/src/http_triggers.rs b/backend/windmill-api/src/http_triggers.rs index 9432fb0e7e..9e75882f39 100644 --- a/backend/windmill-api/src/http_triggers.rs +++ b/backend/windmill-api/src/http_triggers.rs @@ -1,5 +1,8 @@ +use crate::http_trigger_auth::{self}; #[cfg(feature = "parquet")] use crate::job_helpers_ee::get_workspace_s3_resource; +use crate::resources::try_get_resource_from_db_as; +use crate::utils::non_empty_str; use crate::{ args::try_from_request_body, auth::{AuthCache, OptTokened}, @@ -10,6 +13,7 @@ use crate::{ }, users::fetch_api_authed, }; +use axum::response::Response; use axum::{ extract::{Path, Query, Request}, response::IntoResponse, @@ -26,6 +30,7 @@ use std::borrow::Cow; use std::{collections::HashMap, sync::Arc}; use tower_http::cors::CorsLayer; use windmill_audit::{audit_ee::audit_log, ActionKind}; +use windmill_common::error::Error; #[cfg(feature = "parquet")] use windmill_common::s3_helpers::build_object_store_client; use windmill_common::{ @@ -101,6 +106,18 @@ impl TryFrom<&http::Method> for HttpMethod { } } +#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] +#[sqlx(type_name = "AUTHENTICATION_METHOD", rename_all = "snake_case")] +#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))] +pub enum AuthenticationMethod { + None, + Windmill, + ApiKey, + BasicHttp, + CustomScript, + Signature, +} + #[derive(Debug, Deserialize)] struct NewTrigger { path: String, @@ -108,9 +125,10 @@ struct NewTrigger { script_path: String, is_flow: bool, is_async: bool, - requires_auth: bool, - http_method: HttpMethod, + authentication_resource_path: Option, + authentication_method: AuthenticationMethod, static_asset_config: Option>, + http_method: HttpMethod, workspaced_route: Option, is_static_website: bool, wrap_body: Option, @@ -130,11 +148,12 @@ pub struct HttpTrigger { pub edited_at: chrono::DateTime, pub extra_perms: serde_json::Value, pub is_async: bool, - pub requires_auth: bool, + pub authentication_method: AuthenticationMethod, pub http_method: HttpMethod, #[serde(skip_serializing_if = "Option::is_none")] pub static_asset_config: Option>, pub is_static_website: bool, + pub authentication_resource_path: Option, pub workspaced_route: bool, pub wrap_body: bool, pub raw_string: bool, @@ -147,7 +166,9 @@ struct EditTrigger { script_path: String, is_flow: bool, is_async: bool, - requires_auth: bool, + authentication_method: AuthenticationMethod, + #[serde(deserialize_with = "non_empty_str")] + authentication_resource_path: Option, http_method: HttpMethod, static_asset_config: Option>, workspaced_route: Option, @@ -190,9 +211,10 @@ async fn list_triggers( "edited_at", "extra_perms", "is_async", - "requires_auth", + "authentication_method", "static_asset_config", "is_static_website", + "authentication_resource_path", ]) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&w_id)) @@ -243,9 +265,10 @@ async fn get_trigger( edited_at, extra_perms, is_async, - requires_auth, + authentication_method as "authentication_method: _", static_asset_config as "static_asset_config: _", is_static_website, + authentication_resource_path, wrap_body, raw_string FROM @@ -266,6 +289,23 @@ async fn get_trigger( Ok(Json(trigger)) } +fn validate_authentication_method( + authentication_method: AuthenticationMethod, + raw_string: Option, +) -> error::Result<()> { + match (authentication_method, raw_string) { + (AuthenticationMethod::CustomScript, raw) if !raw.unwrap_or(false) == true => { + return Err(Error::BadRequest( + "To use custom script authentication, please enable the raw body option." + .to_string(), + )); + } + _ => {} + } + + Ok(()) +} + async fn create_trigger( authed: ApiAuthed, Extension(db): Extension, @@ -279,6 +319,8 @@ async fn create_trigger( return Err(error::Error::BadRequest("Invalid route path".to_string())); } + validate_authentication_method(ct.authentication_method, ct.raw_string)?; + // route path key is extracted from the route path to check for uniqueness // it replaces /?:{key} with :key // it will also remove the leading / if present, not an issue as we only allow : after slashes @@ -313,12 +355,13 @@ async fn create_trigger( route_path, route_path_key, workspaced_route, + authentication_resource_path, wrap_body, raw_string, script_path, is_flow, is_async, - requires_auth, + authentication_method, http_method, static_asset_config, edited_by, @@ -327,20 +370,21 @@ async fn create_trigger( is_static_website ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, now(), $16 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, now(), $17 ) "#, w_id, ct.path, ct.route_path, &route_path_key, - ct.workspaced_route.unwrap_or(false), + ct.workspaced_route, + ct.authentication_resource_path, ct.wrap_body.unwrap_or(false), ct.raw_string.unwrap_or(false), ct.script_path, ct.is_flow, ct.is_async, - ct.requires_auth, + ct.authentication_method as _, ct.http_method as _, ct.static_asset_config as _, &authed.username, @@ -380,6 +424,8 @@ async fn update_trigger( )); } + validate_authentication_method(ct.authentication_method, ct.raw_string)?; + let mut tx; if authed.is_admin { let Some(route_path) = ct.route_path else { @@ -417,29 +463,31 @@ async fn update_trigger( SET route_path = $1, route_path_key = $2, - workspaced_route = $3, + workspaced_route = $3, wrap_body = $4, raw_string = $5, - script_path = $6, - path = $7, - is_flow = $8, - http_method = $9, - static_asset_config = $10, - edited_by = $11, - email = $12, - is_async = $13, - requires_auth = $14, + authentication_resource_path = $6, + script_path = $7, + path = $8, + is_flow = $9, + http_method = $10, + static_asset_config = $11, + edited_by = $12, + email = $13, + is_async = $14, + authentication_method = $15, edited_at = now(), - is_static_website = $15 + is_static_website = $16 WHERE - workspace_id = $16 AND - path = $17 + workspace_id = $17 AND + path = $18 "#, route_path, &route_path_key, ct.workspaced_route, ct.wrap_body, ct.raw_string, + ct.authentication_resource_path, ct.script_path, ct.path, ct.is_flow, @@ -448,7 +496,7 @@ async fn update_trigger( &authed.username, &authed.email, ct.is_async, - ct.requires_auth, + ct.authentication_method as _, ct.is_static_website, w_id, path, @@ -462,21 +510,29 @@ async fn update_trigger( UPDATE http_trigger SET - script_path = $1, - path = $2, - is_flow = $3, - http_method = $4, - static_asset_config = $5, - edited_by = $6, - email = $7, - is_async = $8, - requires_auth = $9, + workspaced_route = $1, + wrap_body = $2, + raw_string = $3, + authentication_resource_path = $4, + script_path = $5, + path = $6, + is_flow = $7, + http_method = $8, + static_asset_config = $9, + edited_by = $10, + email = $11, + is_async = $12, + authentication_method = $13, edited_at = now(), - is_static_website = $10 + is_static_website = $14 WHERE - workspace_id = $11 AND - path = $12 + workspace_id = $15 AND + path = $16 "#, + ct.workspaced_route, + ct.wrap_body, + ct.raw_string, + ct.authentication_resource_path, ct.script_path, ct.path, ct.is_flow, @@ -485,7 +541,7 @@ async fn update_trigger( &authed.username, &authed.email, ct.is_async, - ct.requires_auth, + ct.authentication_method as _, ct.is_static_website, w_id, path, @@ -652,6 +708,7 @@ async fn exists_route( Ok(Json(exists)) } +#[derive(Debug, Deserialize)] struct TriggerRoute { path: String, script_path: String, @@ -659,11 +716,12 @@ struct TriggerRoute { route_path: String, workspace_id: String, is_async: bool, - requires_auth: bool, + authentication_method: AuthenticationMethod, edited_by: String, email: String, static_asset_config: Option>, is_static_website: bool, + authentication_resource_path: Option, workspaced_route: bool, wrap_body: bool, raw_string: bool, @@ -694,14 +752,15 @@ async fn get_http_route_trigger( route_path, workspace_id, is_async, - requires_auth, + authentication_method AS "authentication_method: _", edited_by, email, static_asset_config AS "static_asset_config: _", wrap_body, raw_string, workspaced_route, - is_static_website + is_static_website, + authentication_resource_path FROM http_trigger WHERE @@ -723,9 +782,10 @@ async fn get_http_route_trigger( script_path, is_flow, route_path, + authentication_resource_path, workspace_id, is_async, - requires_auth, + authentication_method AS "authentication_method: _", edited_by, email, static_asset_config AS "static_asset_config: _", @@ -787,7 +847,7 @@ async fn get_http_route_trigger( .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); - let username_override = if trigger.requires_auth { + let username_override = if let AuthenticationMethod::Windmill = trigger.authentication_method { let opt_authed = if let Some(token) = token { auth_cache .get_authed(Some(trigger.workspace_id.clone()), token) @@ -878,9 +938,9 @@ async fn route_job( method: http::Method, headers: HeaderMap, request: Request, -) -> impl IntoResponse { +) -> Result { let route_path = route_path.to_path().trim_end_matches("/"); - let (trigger, called_path, params, authed) = match get_http_route_trigger( + let (trigger, called_path, params, authed) = get_http_route_trigger( route_path, &auth_cache, token.as_ref(), @@ -889,36 +949,82 @@ async fn route_job( &method, ) .await - { - Ok(trigger) => trigger, - Err(e) => return e.into_response(), - }; + .map_err(|e| e.into_response())?; - let result = try_from_request_body( + let args = try_from_request_body( request, &db, - Some(trigger.raw_string), + Some(match trigger.authentication_method { + AuthenticationMethod::CustomScript | AuthenticationMethod::Signature => true, + _ => trigger.raw_string, + }), Some(trigger.wrap_body), ) - .await; + .await + .map_err(|e| e.into_response())?; - let mut args = match result { - Ok(args) => match args - .to_push_args_owned(&authed, &db, &trigger.workspace_id) - .await - { - Ok(args) => args, - Err(e) => return e.into_response(), - }, - Err(e) => return e.into_response(), - }; + let mut args = args + .to_push_args_owned(&authed, &db, &trigger.workspace_id) + .await + .map_err(|e| e.into_response())?; + + match trigger.authentication_method { + AuthenticationMethod::None + | AuthenticationMethod::Windmill + | AuthenticationMethod::CustomScript => {} + _ => { + let resource_path = match trigger.authentication_resource_path { + Some(resource_path) => resource_path, + None => { + return Err(Error::BadRequest( + "Missing authentication resource path".to_string(), + ) + .into_response()) + } + }; + + let authentication_method = + try_get_resource_from_db_as::( + authed.clone(), + Some(user_db.clone()), + &db, + &resource_path, + &trigger.workspace_id, + ) + .await + .map_err(|e| e.into_response())?; + + let raw_payload = args + .extra + .as_ref() + .and_then(|extra| { + extra + .get("raw_string") + .and_then(|value| Some(value.to_string())) + .and_then(|raw_payload| Some(serde_json::from_str::(&raw_payload))) + }) + .transpose() + .map_err(|e| { + windmill_common::error::Error::SerdeJson { location: e.to_string(), error: e } + .into_response() + })?; + + let response = authentication_method + .authenticate_http_request(&headers, raw_payload.as_ref()) + .map_err(|e| e.into_response())?; + + if let Some(response) = response { + return Ok(response); + } + } + } #[cfg(not(feature = "parquet"))] if trigger.static_asset_config.is_some() { - return error::Error::internal_err( + return Err(error::Error::internal_err( "Static asset configuration is not supported in this build".to_string(), ) - .into_response(); + .into_response()); } #[cfg(feature = "parquet")] @@ -1018,9 +1124,9 @@ async fn route_job( }; match build_static_response_f.await { Ok((status, headers, body_stream)) => { - return (status, headers, body_stream).into_response() + return Ok((status, headers, body_stream).into_response()) } - Err(e) => return e.into_response(), + Err(e) => return Err(e.into_response()), } } @@ -1041,7 +1147,7 @@ async fn route_job( let run_query = RunJobQuery::default(); - if trigger.is_flow { + let response = if trigger.is_flow { if trigger.is_async { run_flow_by_path_inner( authed, @@ -1097,5 +1203,7 @@ async fn route_job( .await .into_response() } - } + }; + + Ok(response) } diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 37bcd80761..589e9fecdb 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -75,6 +75,8 @@ mod granular_acls; mod groups; #[cfg(feature = "http_trigger")] mod http_triggers; +#[cfg(feature = "http_trigger")] +mod http_trigger_auth; mod indexer_ee; mod inputs; mod integration; diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index a3f7ee3d20..e0a182107a 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -1209,6 +1209,7 @@ async fn update_resource_type( } #[cfg(any( + feature = "http_trigger", feature = "postgres_trigger", feature = "mqtt_trigger", all(feature = "sqs_trigger", feature = "enterprise") diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index 1da91ae448..8823dca33e 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -8,7 +8,7 @@ use axum::{body::Body, response::Response}; use regex::Regex; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use sqlx::{Postgres, Transaction}; #[cfg(feature = "enterprise")] use windmill_common::worker::CLOUD_HOSTED; @@ -190,6 +190,15 @@ pub fn content_plain(body: Body) -> Response { .unwrap() } +#[allow(unused)] +pub fn non_empty_str<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let o: Option = Option::deserialize(deserializer)?; + Ok(o.filter(|s| !s.trim().is_empty())) +} + use serde::Serialize; #[derive(Serialize)] diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 4560d9b415..1e4a689b18 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -542,6 +542,7 @@ pub(crate) async fn tarball_workspace( path, route_path, route_path_key, + authentication_resource_path, script_path, is_flow, edited_by, @@ -549,7 +550,7 @@ pub(crate) async fn tarball_workspace( email, extra_perms, is_async, - requires_auth, + authentication_method AS "authentication_method: _", http_method AS "http_method: _", static_asset_config AS "static_asset_config: _", is_static_website, diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index 59c3ca6420..96c8add791 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -173,9 +173,6 @@ pub fn to_anyhow(e: T) -> anyhow:: impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { - let e = &self; - let body = Body::from(e.to_string()); - let status = match self { Self::NotFound(_) => axum::http::StatusCode::NOT_FOUND, Self::NotAuthorized(_) => axum::http::StatusCode::UNAUTHORIZED, @@ -187,12 +184,16 @@ impl IntoResponse for Error { _ => axum::http::StatusCode::INTERNAL_SERVER_ERROR, }; + let e = &self; + if matches!(status, axum::http::StatusCode::NOT_FOUND) { tracing::warn!(message = e.to_string()); } else { tracing::error!(message = e.to_string(), error = ?e); }; + let body = Body::from(e.to_string()); + axum::response::Response::builder() .header("Content-Type", "text/plain") .status(status) diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 22e1cdffe5..813df1c3aa 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -130,7 +130,7 @@ )} style={wrapperStyle} > - {#if href} + {#if href && !disabled} {:else if triggerType === 'email'} diff --git a/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte b/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte new file mode 100644 index 0000000000..4d11c07df1 --- /dev/null +++ b/frontend/src/lib/components/triggers/http/RouteBodyTransformerOption.svelte @@ -0,0 +1,41 @@ + + + + diff --git a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte index 2a5c42df4a..08919daf06 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorConfigSection.svelte @@ -15,10 +15,11 @@ import CaptureSection from '../CaptureSection.svelte' import CaptureTable from '../CaptureTable.svelte' import ClipboardPanel from '../../details/ClipboardPanel.svelte' - import { isCloudHosted } from '$lib/cloud' - import Toggle from '$lib/components/Toggle.svelte' import { isObject } from '$lib/utils' import { getHttpRoute } from './utils' + import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte' + import { isCloudHosted } from '$lib/cloud' + import Toggle from '$lib/components/Toggle.svelte' export let initialTriggerPath: string | undefined = undefined export let dirtyRoutePath: boolean = false @@ -34,6 +35,9 @@ export let workspaced_route: boolean = false export let isValid = false export let runnableArgs: any = {} + export let raw_string: boolean = false + export let wrap_body: boolean = false + export let capture_mode: boolean let validateTimeout: NodeJS.Timeout | undefined = undefined let routeError: string = '' @@ -187,7 +191,7 @@
{dirtyRoutePath ? routeError : ''}
- {#if !isCloudHosted()} + {#if !capture_mode && !isCloudHosted()}
{/if}
+ {#if capture_mode} + + {/if} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index e32991a29a..d1743d1ec7 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -5,13 +5,14 @@ import Path from '$lib/components/Path.svelte' import Required from '$lib/components/Required.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' - import { HttpTriggerService } from '$lib/gen' + import { HttpTriggerService, VariableService, type AuthenticationMethod } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save, Pipette } from 'lucide-svelte' + import { Loader2, Save, Pipette, Plus } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' + import VariableEditor from '../../VariableEditor.svelte' import { json } from 'svelte-highlight/languages' import { Highlight } from 'svelte-highlight' import JsonEditor from '$lib/components/JsonEditor.svelte' @@ -23,7 +24,12 @@ import RouteEditorConfigSection from './RouteEditorConfigSection.svelte' import SimpleEditor from '$lib/components/SimpleEditor.svelte' import { isCloudHosted } from '$lib/cloud' - import Tooltip from '$lib/components/Tooltip.svelte' + import ResourcePicker from '$lib/components/ResourcePicker.svelte' + import ItemPicker from '../../ItemPicker.svelte' + import { Popover } from '$lib/components/meltComponents' + import { HUB_SCRIPT_ID, SECRET_KEY_PATH } from './utils' + import { HubFlow } from '$lib/hub' + import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte' let is_flow: boolean = false let initialPath = '' let edit = true @@ -40,13 +46,12 @@ let isValid = false let dirtyRoutePath = false let is_async = false - let requires_auth = false + let authentication_method: AuthenticationMethod = 'none' let route_path = '' let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'post' let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined = undefined - let is_static_website = false - + let is_static_website: boolean = false let s3FilePicker: S3FilePicker let s3FileUploadRawMode = false let s3Editor: SimpleEditor | undefined = undefined @@ -54,6 +59,55 @@ let raw_string = false let wrap_body = false let drawerLoading = true + let authentication_resource_path: string = '' + let variablePicker: ItemPicker + let variableEditor: VariableEditor + let variable_path: string = '' + type AuthenticationOption = { + label: string + value: AuthenticationMethod + tooltip?: string + resource_type?: string + } + + async function loadVariables() { + return await VariableService.listVariable({ workspace: $workspaceStore ?? '' }) + } + + let signature_options_type: 'custom_script' | 'custom_signature' = 'custom_signature' + + const authentication_options: AuthenticationOption[] = [ + { + label: 'No Auth', + value: 'none' + }, + { + label: 'Windmill', + value: 'windmill', + tooltip: 'Requires the user to be authenticated with read access to this route' + }, + { + label: 'API Key', + value: 'api_key', + resource_type: 'api_key_auth', + tooltip: + 'Checks for a valid API key in a specified header. Header name can be configured in the resource.' + }, + { + label: 'Basic Auth', + value: 'basic_http', + resource_type: 'basic_http_auth', + tooltip: + 'Uses base64-encoded Basic authentication. Defaults to the Authorization header, but a custom header can be configured in the resource.' + }, + { + label: 'Signature', + value: 'signature', + tooltip: + 'Validates requests using HMAC or signature-based authentication. Can be custom or follow predefined formats from common providers.' + } + ] + export async function openEdit(ePath: string, isFlow: boolean) { drawerLoading = true try { @@ -83,7 +137,7 @@ edit = false itemKind = nis_flow ? 'flow' : 'script' is_async = false - requires_auth = false + authentication_method = 'none' route_path = defaultValues?.route_path ?? '' dirtyRoutePath = false http_method = defaultValues?.http_method ?? 'post' @@ -97,6 +151,11 @@ dirtyPath = false is_static_website = false workspaced_route = false + authentication_resource_path = '' + variable_path = '' + signature_options_type = 'custom_signature' + raw_string = defaultValues?.raw_string ?? false + wrap_body = defaultValues?.wrap_body ?? false } finally { drawerLoading = false } @@ -118,11 +177,17 @@ route_path = s.route_path http_method = s.http_method ?? 'post' is_async = s.is_async - requires_auth = s.requires_auth workspaced_route = s.workspaced_route wrap_body = s.wrap_body raw_string = s.raw_string - + authentication_resource_path = s.authentication_resource_path ?? '' + if (s.authentication_method === 'custom_script') { + authentication_method = 'signature' + signature_options_type = 'custom_script' + } else { + authentication_method = s.authentication_method + signature_options_type = 'custom_signature' + } if (!isCloudHosted()) { static_asset_config = s.static_asset_config s3FileUploadRawMode = !!static_asset_config @@ -133,6 +198,14 @@ } async function triggerScript(): Promise { + // If the user selects "signature" with the "custom_script" option, + // we explicitly set the authentication method to "custom_script" + // (which is a valid enum on its own in the backend) + const auth_method: AuthenticationMethod = + authentication_method === 'signature' && signature_options_type === 'custom_script' + ? 'custom_script' + : authentication_method + if (edit) { await HttpTriggerService.updateHttpTrigger({ workspace: $workspaceStore!, @@ -142,12 +215,13 @@ script_path, is_flow, is_async, - requires_auth, + authentication_method: auth_method, route_path: $userStore?.is_admin || $userStore?.is_super_admin ? route_path : undefined, http_method, static_asset_config, is_static_website, workspaced_route, + authentication_resource_path, wrap_body, raw_string } @@ -161,12 +235,13 @@ script_path, is_flow, is_async, - requires_auth, + authentication_method: auth_method, route_path, http_method, static_asset_config, is_static_website, workspaced_route, + authentication_resource_path, wrap_body, raw_string } @@ -179,7 +254,6 @@ dispatch('update') drawer.closeDrawer() } - let drawer: Drawer let dirtyPath = false @@ -259,7 +333,7 @@ is_async = false is_static_website = ev.detail === 'static_website' if (is_static_website) { - requires_auth = false + authentication_method = 'none' } } else if (ev.detail === 'runnable') { static_asset_config = undefined @@ -349,31 +423,35 @@ {:else} -

+

Pick a script or flow to be triggered
To handle headers, query or path parameters, add a preprocessor to your runnable.

-
- +
+
+ - {#if script_path === undefined} - - {/if} + {#if script_path === undefined} + + {/if} +
{/if} @@ -386,6 +464,7 @@ bind:http_method bind:workspaced_route {can_write} + capture_mode={false} bind:static_asset_config /> @@ -426,55 +505,124 @@ { - requires_auth = detail === 'required' + bind:selected={authentication_method} + on:selected={(e) => { + if (e.detail === 'signature' && signature_options_type === 'custom_script') { + raw_string = true + } }} disabled={!can_write} let:item > - - + {#each authentication_options as option} + {#if option.value === 'signature'} + + + + + + { + if (e.detail === 'custom_script') { + if (!raw_string) { + raw_string = true + } + } + }} + disabled={!can_write} + let:item + > + + + + + + {:else} + + {/if} + {/each} - - + {:else if signature_options_type === 'custom_script'} +

+ Pick a secret variable or create one which will be used as a secret key for your + custom script/flow
+

+
+
+ + +
+ +
+ {/if} + {/if} + +
{/if} @@ -482,3 +630,32 @@ {/if} + + { + variable_path = path + }} + tooltip="Variables are dynamic values that have a key associated to them and can be retrieved during the execution of a Script or Flow." + documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets" + itemName="Variable" + extraField="path" + loadItems={loadVariables} + buttons={{ 'Edit/View': (x) => variableEditor.editVariable(x) }} +> +
+ +
+
+ + diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index fbcaa55114..c6b7263227 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -1,8 +1,26 @@ -import { base } from "$lib/base"; -import { isCloudHosted } from "$lib/cloud"; +import { base } from '$lib/base' +import { isCloudHosted } from '$lib/cloud' -export function getHttpRoute(route_path: string | undefined, workspaced_route: boolean, workspace_id: string) { - return `${location.origin}${base}/api/r/${ - isCloudHosted() || workspaced_route ? workspace_id + '/' : '' - }${route_path ?? ''}` -} \ No newline at end of file +export const SECRET_KEY_PATH = 'secret_key_path' +export const HUB_SCRIPT_ID = 11663 +export const SIGNATURE_TEMPLATE_SCRIPT_HUB_PATH: string = `hub/${HUB_SCRIPT_ID}` +export const SIGNATURE_TEMPLATE_FLOW_HUB_ID = '67' + +export function getHttpRoute( + route_path: string | undefined, + workspaced_route: boolean, + workspace_id: string +) { + return `${location.origin}${base}/api/r/${ + isCloudHosted() || workspaced_route ? workspace_id + '/' : '' + }${route_path ?? ''}` +} + +export function replacePlaceholderForSignatureScriptTemplate(content: string) { + const params = new URLSearchParams(window.location.search) + const secret_key_path = params.get(SECRET_KEY_PATH) ?? '' + return content.replace( + /(const\s+SECRET_KEY_VARIABLE_PATH\s*=\s*")[^"]*(";)/, + `$1${secret_key_path}$2` + ) +} diff --git a/frontend/src/lib/hub.ts b/frontend/src/lib/hub.ts index c856775612..df9e9e3ffc 100644 --- a/frontend/src/lib/hub.ts +++ b/frontend/src/lib/hub.ts @@ -2,6 +2,11 @@ import type { Schema } from './common' import { AppService, FlowService, type Flow, type Script } from './gen' import { encodeState } from './utils' import rawHubPaths from './hubPaths.json?raw' +import { + replacePlaceholderForSignatureScriptTemplate, + SIGNATURE_TEMPLATE_FLOW_HUB_ID, + SIGNATURE_TEMPLATE_SCRIPT_HUB_PATH +} from './components/triggers/http/utils' export function scriptToHubUrl( content: string, @@ -19,6 +24,24 @@ export function scriptToHubUrl( return url } +export const HubScript = { + SIGNATURE_TEMPLATE: SIGNATURE_TEMPLATE_SCRIPT_HUB_PATH +} as const + +export const HubFlow = { + SIGNATURE_TEMPLATE: SIGNATURE_TEMPLATE_FLOW_HUB_ID +} as const + +export function replaceScriptPlaceholderWithItsValues(id: string, content: string) { + switch (id) { + case HubScript.SIGNATURE_TEMPLATE: + case HubFlow.SIGNATURE_TEMPLATE: + return replacePlaceholderForSignatureScriptTemplate(content) + default: + return content + } +} + export async function loadHubFlows() { try { const flows = (await FlowService.listHubFlows()).flows ?? [] diff --git a/frontend/src/lib/utils_deployable.ts b/frontend/src/lib/utils_deployable.ts index 7f3d55a56f..2147777d44 100644 --- a/frontend/src/lib/utils_deployable.ts +++ b/frontend/src/lib/utils_deployable.ts @@ -318,8 +318,9 @@ export async function getTriggerValue(kind: TriggerKind, path: string, workspace route_path, static_asset_config, is_async, - requires_auth, - is_static_website + authentication_method, + is_static_website, + authentication_resource_path } = await HttpTriggerService.getHttpTrigger({ workspace: workspace!, path: path @@ -332,8 +333,9 @@ export async function getTriggerValue(kind: TriggerKind, path: string, workspace route_path, static_asset_config, is_async, - requires_auth, - is_static_website + authentication_method, + is_static_website, + authentication_resource_path } } else if (kind === 'schedules') { const { @@ -479,12 +481,12 @@ export async function getTriggerDependency(kind: TriggerKind, path: string, work } }) } else if (kind === 'routes') { - const { script_path, is_flow } = await HttpTriggerService.getHttpTrigger({ + const { script_path, is_flow, authentication_resource_path} = await HttpTriggerService.getHttpTrigger({ workspace: workspace!, path: path }) - result = retrieveKindsValues({ script_path, is_flow }) + result = retrieveKindsValues({ script_path, is_flow, resource_path: authentication_resource_path}) } else if (kind === 'schedules') { const { script_path, is_flow } = await ScheduleService.getSchedule({ workspace: workspace!, diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 2ebae10bac..e291917235 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -15,6 +15,7 @@ import { writable } from 'svelte/store' import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' + import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' let nodraft = $page.url.searchParams.get('nodraft') @@ -130,6 +131,12 @@ delete hub['comments'] initialPath = `u/${$userStore?.username}/flow_${hubId}` Object.assign(flow, hub.flow) + if (flow.value.preprocessor_module?.value.type === 'rawscript') { + flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues( + hubId, + flow.value.preprocessor_module.value.content + ) + } flow = flow goto('?', { replaceState: true }) selectedId = 'constants' diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 1def25b2d4..129f01a47c 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -11,6 +11,7 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' + import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' // Default let schema: Schema = emptySchema() @@ -87,7 +88,7 @@ path: hubPath }) script.description = `Fork of ${hubPath}` - script.content = content + script.content = replaceScriptPlaceholderWithItsValues(hubPath, content) script.summary = summary ?? '' script.language = language as Script['language'] scriptBuilder?.setCode(script.content) diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index 93c32b3bf0..5086fa7934 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -21,7 +21,7 @@ import ShareModal from '$lib/components/ShareModal.svelte' import Toggle from '$lib/components/Toggle.svelte' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' - import { Code, Eye, Pen, Plus, Share, Trash, Circle, Database, FileUp } from 'lucide-svelte' + import { Code, Eye, Pen, Plus, Share, Trash, Circle, FileUp } from 'lucide-svelte' import { goto } from '$lib/navigation' import SearchItems from '$lib/components/SearchItems.svelte' import NoItemFound from '$lib/components/home/NoItemFound.svelte' @@ -36,6 +36,7 @@ import SqsTriggerEditor from '$lib/components/triggers/sqs/SqsTriggerEditor.svelte' import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable' import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' + import { AwsIcon } from '$lib/components/icons' type TriggerD = SqsTrigger & { canWrite: boolean } @@ -250,7 +251,7 @@
Filter by path of
- +
@@ -418,7 +419,7 @@ displayName: canWrite ? 'Share' : 'See Permissions', icon: Share, action: () => { - shareModal.openDrawer(path, 'websocket_trigger') + shareModal.openDrawer(path, 'sqs_trigger') } } ]}