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 <hugo@casademont.ch>
This commit is contained in:
dieriba
2025-03-27 09:43:42 +01:00
committed by GitHub
parent a5eb3d403c
commit e920101107
37 changed files with 1726 additions and 351 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "ddf2eccb78a310ed00c7d8b9c3f05d394a7cbcf0038c72a78add5c7b02ef5927"
@@ -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"
}
+4
View File
@@ -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",
+4
View File
@@ -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"] }
@@ -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;
@@ -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;
+3 -1
View File
@@ -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 }
+26 -9
View File
@@ -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
+42 -11
View File
@@ -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<bool>,
wrap_body: Option<bool>,
}
#[cfg(all(feature = "enterprise", feature = "kafka"))]
@@ -643,8 +649,12 @@ async fn http_payload(
Query(query): Query<HashMap<String, String>>,
method: http::Method,
headers: HeaderMap,
args: WebhookArgs,
) -> Result<StatusCode> {
request: Request,
) -> std::result::Result<StatusCode, impl IntoResponse> {
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<String, Box<RawValue>> = 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)
}
@@ -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<Sha256>;
pub type HmacSha512 = Hmac<Sha512>;
pub type HmacSha1 = Hmac<Sha1>;
mod github {
use super::*;
pub struct Github;
impl WebhookHandler for Github {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<RawValue>,
}
pub struct Twitch;
impl WebhookHandler for Twitch {
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<Option<Response>, 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::<TwitchCrcBody>(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<Option<Response>, AuthenticationError> {
let Ok(zoom_request_body) = serde_json::from_str::<ZoomChallengeResponse>(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<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<Option<Response>, AuthenticationError>;
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, 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<String>,
}
pub struct SignatureConfigData<'config> {
secret_key: &'config str,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SignatureAuthentication {
signature_provider: WebhookType,
secret_key: String,
authentication_config: Option<SignatureAuthenticationMethod>,
}
#[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<HeaderValue> {
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<u8> {
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<Option<Response>, 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()
}
}
+175 -67
View File
@@ -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<String>,
authentication_method: AuthenticationMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
http_method: HttpMethod,
workspaced_route: Option<bool>,
is_static_website: bool,
wrap_body: Option<bool>,
@@ -130,11 +148,12 @@ pub struct HttpTrigger {
pub edited_at: chrono::DateTime<chrono::Utc>,
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<sqlx::types::Json<S3Object>>,
pub is_static_website: bool,
pub authentication_resource_path: Option<String>,
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<String>,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
workspaced_route: Option<bool>,
@@ -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<bool>,
) -> 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<DB>,
@@ -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<sqlx::types::Json<S3Object>>,
is_static_website: bool,
authentication_resource_path: Option<String>,
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<impl IntoResponse, Response> {
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::<http_trigger_auth::AuthenticationMethod>(
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::<String>(&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)
}
+2
View File
@@ -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;
+1
View File
@@ -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")
+10 -1
View File
@@ -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<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let o: Option<String> = Option::deserialize(deserializer)?;
Ok(o.filter(|s| !s.trim().is_empty()))
}
use serde::Serialize;
#[derive(Serialize)]
@@ -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,
+4 -3
View File
@@ -173,9 +173,6 @@ pub fn to_anyhow<T: 'static + std::error::Error + Send + Sync>(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)
@@ -130,7 +130,7 @@
)}
style={wrapperStyle}
>
{#if href}
{#if href && !disabled}
<a
bind:this={element}
on:pointerdown
@@ -255,6 +255,9 @@
runnableArgs={data?.args}
bind:route_path={args.route_path}
bind:http_method={args.http_method}
bind:raw_string={args.raw_string}
bind:wrap_body={args.wrap_body}
capture_mode={true}
headless
{captureInfo}
bind:captureTable
@@ -57,6 +57,9 @@
can_write={true}
bind:route_path={args.route_path}
bind:http_method={args.http_method}
bind:raw_string={args.raw_string}
bind:wrap_body={args.wrap_body}
capture_mode={true}
headless
/>
{:else if triggerType === 'email'}
@@ -0,0 +1,41 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
export let raw_string: boolean
export let wrap_body: boolean
</script>
<Label label="Raw body" class="w-full">
<svelte:fragment slot="header">
<Tooltip>
Provides the raw JSON payload as a string under the 'raw_string' key. Required for custom
script authentication method and useful for signature verification or other advanced use
cases.
</Tooltip>
</svelte:fragment>
<svelte:fragment slot="action">
<Toggle
checked={raw_string}
on:change={() => {
raw_string = !raw_string
}}
/>
</svelte:fragment>
</Label>
<Label label="Wrap body" class="w-full">
<svelte:fragment slot="header">
<Tooltip
>Wraps the payload in an object under the 'body' key, useful for handling unknown payloads.</Tooltip
>
</svelte:fragment>
<svelte:fragment slot="action">
<Toggle
checked={wrap_body}
on:change={() => {
wrap_body = !wrap_body
}}
/>
</svelte:fragment>
</Label>
@@ -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 @@
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyRoutePath ? routeError : ''}</div
>
{#if !isCloudHosted()}
{#if !capture_mode && !isCloudHosted()}
<div class="mt-1">
<Toggle
size="sm"
@@ -205,6 +209,9 @@
</div>
{/if}
</div>
{#if capture_mode}
<RouteBodyTransformerOption bind:raw_string bind:wrap_body />
{/if}
</div>
</Section>
</div>
@@ -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<void> {
// 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 @@
</div>
</div>
{:else}
<p class="text-xs mt-0.5 mb-1 text-tertiary">
<p class="text-xs mt-3 mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} /><br />
To handle headers, query or path parameters, add a preprocessor to your runnable.
</p>
<div class="flex flex-row mb-2">
<ScriptPicker
disabled={fixedScriptPath != '' || !can_write}
initialPath={fixedScriptPath || initialScriptPath}
kinds={['script']}
allowFlow={true}
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
/>
<div class="flex flex-col gap-2">
<div class="flex flex-row mb-2">
<ScriptPicker
disabled={fixedScriptPath != '' || !can_write}
initialPath={fixedScriptPath || initialScriptPath}
kinds={['script']}
allowFlow={true}
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
/>
{#if script_path === undefined}
<Button
btnClasses="ml-4 mt-2"
color="dark"
size="xs"
href={itemKind === 'flow' ? '/flows/add?hub=62' : '/scripts/add?hub=hub%2F11627'}
target="_blank">Create from template</Button
>
{/if}
{#if script_path === undefined}
<Button
btnClasses="ml-4 mt-2"
color="dark"
size="xs"
href={itemKind === 'flow'
? '/flows/add?hub=62'
: '/scripts/add?hub=hub%2F11627'}
target="_blank">Create from template</Button
>
{/if}
</div>
</div>
{/if}
</Section>
@@ -386,6 +464,7 @@
bind:http_method
bind:workspaced_route
{can_write}
capture_mode={false}
bind:static_asset_config
/>
@@ -426,55 +505,124 @@
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
selected={requires_auth ? 'required' : 'none'}
on:selected={({ detail }) => {
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
>
<ToggleButton label="None" value="none" {item} />
<ToggleButton
label="Required"
value="required"
tooltip="Requires authentication with read access on the route"
{item}
/>
{#each authentication_options as option}
{#if option.value === 'signature'}
<Popover placement="top-end" usePointerDownOutside>
<svelte:fragment slot="trigger">
<ToggleButton
label={option.label}
value={option.value}
tooltip={option.tooltip}
{item}
/>
</svelte:fragment>
<svelte:fragment slot="content">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={signature_options_type}
on:selected={(e) => {
if (e.detail === 'custom_script') {
if (!raw_string) {
raw_string = true
}
}
}}
disabled={!can_write}
let:item
>
<ToggleButton
label="Signature validation"
value="custom_signature"
tooltip="Use a predefined or custom signature-based authentication scheme"
{item}
/>
<ToggleButton
label="Custom script"
value="custom_script"
tooltip="Use your own script logic"
{item}
/>
</ToggleButtonGroup>
</svelte:fragment>
</Popover>
{:else}
<ToggleButton
label={option.label}
value={option.value}
tooltip={option.tooltip}
{item}
/>
{/if}
{/each}
</ToggleButtonGroup>
</svelte:fragment>
</Label>
<Label label="Raw string" class="w-full">
<svelte:fragment slot="header">
<Tooltip
>Provides the raw JSON payload as a string under the 'raw_string' key, useful for
signature verification and other use cases.</Tooltip
>
</svelte:fragment>
<svelte:fragment slot="action">
<Toggle
checked={raw_string}
on:change={() => {
raw_string = !raw_string
}}
{#each authentication_options as option}
{#if option.resource_type && authentication_method === option.value}
<ResourcePicker
bind:value={authentication_resource_path}
resourceType={option.resource_type}
/>
</svelte:fragment>
</Label>
<Label label="Wrap body" class="w-full">
<svelte:fragment slot="header">
<Tooltip
>Wraps the payload in an object under the 'body' key, useful for handling
unknown payloads.</Tooltip
>
</svelte:fragment>
<svelte:fragment slot="action">
<Toggle
checked={wrap_body}
on:change={() => {
wrap_body = !wrap_body
}}
{/if}
{/each}
{#if authentication_method === 'signature'}
{#if signature_options_type === 'custom_signature'}
<ResourcePicker
bind:value={authentication_resource_path}
resourceType={'signature_auth'}
/>
</svelte:fragment>
</Label>
{:else if signature_options_type === 'custom_script'}
<p class="text-xs mt-3 mb-1 text-tertiary">
Pick a secret variable or create one which will be used as a secret key for your
custom script/flow<Required required={true} /><br />
</p>
<div class="flex flex-row gap-2">
<div class="flex flex-row gap-2 w-full">
<input
type="text"
autocomplete="off"
bind:value={variable_path}
readonly
disabled={true}
/>
<Button
title="Add variable"
on:click={variablePicker.openDrawer}
size="xs"
color="dark"
>
Pick variable
</Button>
</div>
<Button
disabled={emptyString(variable_path)}
color="dark"
size="xs"
href={itemKind === 'flow'
? `/flows/add?${SECRET_KEY_PATH}=${encodeURIComponent(variable_path)}&hub=${
HubFlow.SIGNATURE_TEMPLATE
}`
: `/scripts/add?${SECRET_KEY_PATH}=${encodeURIComponent(
variable_path
)}&hub=hub%2F${HUB_SCRIPT_ID}`}
target="_blank">Create from template</Button
>
</div>
{/if}
{/if}
<RouteBodyTransformerOption bind:raw_string bind:wrap_body />
</div>
</Section>
{/if}
@@ -482,3 +630,32 @@
{/if}
</DrawerContent>
</Drawer>
<ItemPicker
bind:this={variablePicker}
pickCallback={(path, _) => {
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) }}
>
<div slot="submission" class="flex flex-row">
<Button
variant="border"
color="blue"
size="sm"
startIcon={{ icon: Plus }}
on:click={() => {
variableEditor.initNew()
}}
>
New variable
</Button>
</div>
</ItemPicker>
<VariableEditor bind:this={variableEditor} on:create={variablePicker.openDrawer} />
@@ -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 ?? ''}`
}
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`
)
}
+23
View File
@@ -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 ?? []
+8 -6
View File
@@ -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!,
@@ -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'
@@ -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)
@@ -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 @@
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind} let:item>
<ToggleButton small value="trigger" label="SQS trigger" icon={Database} {item} />
<ToggleButton small value="trigger" label="SQS trigger" icon={AwsIcon} {item} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} {item} />
</ToggleButtonGroup>
</div>
@@ -418,7 +419,7 @@
displayName: canWrite ? 'Share' : 'See Permissions',
icon: Share,
action: () => {
shareModal.openDrawer(path, 'websocket_trigger')
shareModal.openDrawer(path, 'sqs_trigger')
}
}
]}