mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
feat: deploy from the UI to a workspace on another instance (#11245)
* feat: deploy from the UI to a workspace on another instance Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the remote deploy proxy from being spent by a link Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: key remote deploy proxy URLs instead of a global client header Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the remote deploy proxy key out of logs and restricted hands Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: serialize remote deploy connect with account and key changes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: key remote deploy tokens to the account and connect by signing in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: bind remote deploy connect to its target and order its locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: serialize remote deploy connect with target changes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: list every lock remote deploy connect takes in auth-surface Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: never wait on the membership lock in remote deploy connect Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a superseded target response out of the settings form Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: void stale remote deploy tokens on read instead of locking in connect Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: void remote deploy tokens older than the last target change Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: order remote deploy connections by when their connect started Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: bind stored remote deploy tokens to the membership and target they were connected under Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: answer remote deploy connect without settings as no target Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2699d5065e
commit
4d12ea4614
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at, member_since, target_changed_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::timestamptz IS NOT NULL\n OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,\n token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,\n proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,\n member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at\n WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at\n RETURNING connected_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "connected_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "remote_deploy_target",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET token = $1\n WHERE workspace_id = $2 AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, token FROM remote_deploy_token\n WHERE workspace_id = $1 AND token <> '' FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT clock_timestamp() AS \"started_at!\", s.remote_deploy_target,\n s.remote_deploy_target_changed_at,\n (SELECT u.created_at FROM usr u\n WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since\n FROM workspace_settings s WHERE s.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at!",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "remote_deploy_target",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "remote_deploy_target_changed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "member_since",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)\n VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',\n 'test@windmill.dev', 'read only', false, true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'\n WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t\n JOIN workspace_settings s ON s.workspace_id = t.workspace_id\n WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3\n AND t.remote_workspace_id = $4 AND t.token <> ''\n AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at\n AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id\n AND u.email = t.email AND u.created_at = t.member_since)\n OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "remote_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "proxy_key",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "connected_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at)\n VALUES ($1, $2, '', '', '', '', '', clock_timestamp())\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n token = '', proxy_key = '', connected_at = clock_timestamp()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1\n AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,\n remote_deploy_target_changed_at = CASE\n WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()\n ELSE remote_deploy_target_changed_at END\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET token = 'not-under-this-key'\n WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',\n 'test@windmill.dev', 'gone', true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n ))\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n AND NOT starts_with(label, 'remote-deploy:')\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99"
|
||||
"hash": "b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()\n WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr SET created_at = created_at - interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76"
|
||||
}
|
||||
Generated
+3
@@ -15526,6 +15526,7 @@ version = "1.816.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
"constant_time_eq 0.3.1",
|
||||
"futures",
|
||||
"hex",
|
||||
"http 1.5.0",
|
||||
@@ -15533,6 +15534,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"regex",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -15541,6 +15543,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"windmill-api-auth",
|
||||
"windmill-api-jobs",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS remote_deploy_token;
|
||||
ALTER TABLE workspace_settings
|
||||
DROP COLUMN IF EXISTS remote_deploy_target,
|
||||
DROP COLUMN IF EXISTS remote_deploy_target_changed_at;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- The workspace on another Windmill instance this workspace deploys into from the UI:
|
||||
-- `{"base_url": ..., "workspace_id": ...}`, and when it last changed. A token connected under an
|
||||
-- earlier change counts for nothing, even once the setting points back at the target it was for.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN remote_deploy_target JSONB,
|
||||
ADD COLUMN remote_deploy_target_changed_at TIMESTAMPTZ;
|
||||
|
||||
-- One user's token for the remote deploy target, encrypted with the workspace key.
|
||||
-- `base_url`/`remote_workspace_id` name the target it was granted for: a token is only
|
||||
-- ever sent to that target, so re-pointing the workspace setting cannot redirect it.
|
||||
-- Keyed by the account rather than by membership, since a superadmin deploys from workspaces
|
||||
-- it is not a member of. The account key cascades: whatever deletes or renames an account
|
||||
-- takes its tokens along, so a later account with the same address cannot inherit them.
|
||||
CREATE TABLE remote_deploy_token (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
base_url VARCHAR(1000) NOT NULL,
|
||||
remote_workspace_id VARCHAR(50) NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
remote_email VARCHAR(255) NOT NULL,
|
||||
-- Part of every proxy URL, and readable only by its owner through the API: a link from
|
||||
-- elsewhere, which rides the session cookie, cannot know it and so cannot spend the token.
|
||||
proxy_key VARCHAR(64) NOT NULL,
|
||||
-- When the connect that wrote the row started, or when a disconnect emptied it.
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- What the connect ran under: the `created_at` of the owner's membership (NULL for a
|
||||
-- superadmin with none) and the target's `remote_deploy_target_changed_at`. The row counts
|
||||
-- only while both are still the same, so a re-add or a target change voids it.
|
||||
member_since TIMESTAMPTZ,
|
||||
target_changed_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (workspace_id, email)
|
||||
);
|
||||
|
||||
GRANT ALL ON remote_deploy_token TO windmill_user;
|
||||
GRANT ALL ON remote_deploy_token TO windmill_admin;
|
||||
@@ -171,6 +171,9 @@ postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(cha
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
remote_deploy_token: workspace_id(char), email(char), base_url(char), remote_workspace_id(char), token(text), remote_email(char), proxy_key(char), connected_at(timestamptz), member_since(timestamptz), target_changed_at(timestamptz)
|
||||
FK: (email) -> password(email)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool), display_name(char)
|
||||
@@ -234,7 +237,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_common::variables::{build_crypt, encrypt};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
/// The deploy target is this very server, which the proxy has no way to tell from another
|
||||
/// instance: it only ever knows the configured URL, the caller's stored token, and the path.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_remote_deploy_proxy(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/remote_deploy");
|
||||
|
||||
// Planted rather than set through the route, which is enterprise-gated. Unlike the route, it
|
||||
// deletes no token for another target, which is what a connect landing just after the
|
||||
// route's cleanup leaves behind.
|
||||
let set_target = |base_url: String, workspace_id: &'static str| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings
|
||||
SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
json!({ "base_url": base_url, "workspace_id": workspace_id })
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
set_target(format!("http://localhost:{port}"), "test-workspace").await;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let status = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(status["target"]["workspace_id"], "test-workspace");
|
||||
assert!(status["connection"].is_null());
|
||||
|
||||
// Deploying before connecting names the step that is missing, rather than reaching the target
|
||||
// with the caller's credentials for this instance.
|
||||
let resp = authed(client().get(format!("{base}/proxy/none/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(resp.text().await?.contains("Connect to"));
|
||||
|
||||
let target =
|
||||
json!({ "base_url": format!("http://localhost:{port}"), "workspace_id": "test-workspace" });
|
||||
|
||||
// A token obtained for another target is refused before it is sent anywhere.
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": { "base_url": format!("http://localhost:{port}"), "workspace_id": "elsewhere" }}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// A token the target refuses is not stored.
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "not-a-token", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let connection = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(connection["remote_email"], "test@windmill.dev");
|
||||
let key = connection["proxy_key"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
// Whatever the remote returns is served from this origin, so it must never render as a page.
|
||||
let csp = resp.headers()["content-security-policy"]
|
||||
.to_str()?
|
||||
.to_string();
|
||||
assert!(csp.starts_with("sandbox"), "{csp}");
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["email"],
|
||||
"test@windmill.dev"
|
||||
);
|
||||
|
||||
// A link from elsewhere rides the session cookie but cannot know the key, so it cannot spend
|
||||
// the stored token.
|
||||
// Nor can a credential that may not use the proxy read the key, to build such a link itself.
|
||||
sqlx::query!(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)
|
||||
VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',
|
||||
'test@windmill.dev', 'read only', false, true)"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = client()
|
||||
.get(format!("{base}/target"))
|
||||
.header("Authorization", "Bearer READ_ONLY_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
let guessed = "x".repeat(key.len());
|
||||
let resp = authed(client().get(format!("{base}/proxy/{guessed}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// A connect locks nothing against a key rotation, so its row can land under the old key: that
|
||||
// is no connection, which the drawer offers to replace, rather than an error on every deploy.
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET token = 'not-under-this-key'
|
||||
WHERE workspace_id = 'test-workspace'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
// A target that refuses the stored token must not answer 401: the browser reads an
|
||||
// unhandled 401 as its own session having expired and logs the user out of this instance.
|
||||
let mc = build_crypt(&db, "test-workspace").await?;
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'",
|
||||
encrypt(&mc, "revoked-token")
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 502);
|
||||
|
||||
// Nor does a connect lock anything against a removal from the workspace, so its row can land
|
||||
// after the removal cleared the table: a row from an earlier membership must not come back
|
||||
// with a re-add, even one whose `created_at` reads earlier than the connect (it is the start
|
||||
// of the re-adding transaction).
|
||||
let as_test2 =
|
||||
|builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer SECRET_TOKEN_2");
|
||||
let resp = as_test2(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN_2", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let key2 = resp.json::<serde_json::Value>().await?["proxy_key"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let resp = as_test2(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["connection"]["proxy_key"],
|
||||
key2.as_str()
|
||||
);
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET created_at = created_at - interval '1 hour'
|
||||
WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = as_test2(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
let resp = as_test2(client().get(format!("{base}/proxy/{key2}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(resp.text().await?.contains("Connect to"));
|
||||
|
||||
// Deleting the account must take its token with it, or the next account created with that
|
||||
// address would act on the remote as this one.
|
||||
let resp = authed(client().delete(format!(
|
||||
"http://localhost:{port}/api/users/delete/test2@windmill.dev"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let left = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(left, Some(0));
|
||||
|
||||
// The token was granted for one target and is never sent to another, so re-pointing the
|
||||
// workspace leaves the caller unconnected instead of handing its token to the new target.
|
||||
set_target(format!("http://localhost:{port}"), "other-workspace").await;
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// The row for the old target outlived the change, as one from a connect in flight across it
|
||||
// would: pointing the setting back must not revive it, even with a stamp that reads earlier
|
||||
// than the connect.
|
||||
set_target(format!("http://localhost:{port}"), "test-workspace").await;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'
|
||||
WHERE workspace_id = 'test-workspace'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
// A connect in flight across a disconnect must not undo it when it lands. The disconnect is
|
||||
// stamped an hour ahead, as if every connect starting now had started before it.
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let resp = authed(client().post(format!("{base}/disconnect")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'
|
||||
WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
// A superadmin outside the workspace has no membership to bind to, so the row is bound to the
|
||||
// account by the credential making the request: an account deleted during the remote call,
|
||||
// and its address taken by another, must not inherit it. A deleted credential that auth still
|
||||
// holds in its cache stands in for one whose account went away mid-call.
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',
|
||||
'test@windmill.dev', 'gone', true)"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let as_gone =
|
||||
|builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer GONE_TOKEN");
|
||||
let resp = as_gone(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
sqlx::query!("DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = as_gone(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2563,6 +2563,14 @@ pub async fn delete_workspace_user_internal(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
email_to_delete,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
|
||||
username_to_delete,
|
||||
@@ -4015,6 +4023,7 @@ async fn update_token_label(
|
||||
AND NOT starts_with(label, 'sdk_app:')
|
||||
AND NOT starts_with(label, 'impersonation:')
|
||||
AND NOT starts_with(label, 'cli-login:')
|
||||
AND NOT starts_with(label, 'remote-deploy:')
|
||||
))
|
||||
RETURNING token_prefix",
|
||||
req.label.as_deref(),
|
||||
@@ -4061,13 +4070,25 @@ async fn leave_workspace(
|
||||
&format!("u/{}", authed.username),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
let left = sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
|
||||
&w_id,
|
||||
authed.username
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.rows_affected();
|
||||
// A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop
|
||||
// its connection.
|
||||
if left > 0 {
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
&authed.email,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
|
||||
@@ -35,6 +35,7 @@ windmill-store.workspace = true
|
||||
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
constant_time_eq.workspace = true
|
||||
futures = { workspace = true, optional = true }
|
||||
hex.workspace = true
|
||||
magic-crypt.workspace = true
|
||||
@@ -42,6 +43,7 @@ http.workspace = true
|
||||
hyper.workspace = true
|
||||
lazy_static.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -49,5 +51,6 @@ sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-postgres.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod datatable_permissions;
|
||||
pub mod datatable_permissions_oss;
|
||||
pub mod datatable_replay_oss;
|
||||
pub mod deployment_requests;
|
||||
pub mod remote_deploy;
|
||||
pub mod workspaces;
|
||||
pub mod workspaces_extra;
|
||||
pub mod workspaces_oss;
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2026
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Deploying from the UI into a workspace of another Windmill instance.
|
||||
//!
|
||||
//! The browser runs the same deploy it runs between two workspaces of this instance, and sends
|
||||
//! every call aimed at the target through [`proxy`], which forwards it to the remote workspace
|
||||
//! with the caller's own token for that instance. The remote authorizes and audits the deploy as
|
||||
//! that person, so nothing here needs to know what a deploy is made of.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{DefaultBodyLimit, Extension, OriginalUri, Path},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
response::Response,
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use magic_crypt::MagicCrypt256;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened};
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
auth::hash_token,
|
||||
error::{error_source_chain, Error, Result},
|
||||
ssrf::validate_url_for_ssrf,
|
||||
utils::{configure_client, rd_string, require_admin},
|
||||
variables::{build_crypt, decrypt, encrypt},
|
||||
worker::CLOUD_HOSTED,
|
||||
DB,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref REMOTE_WORKSPACE_ID: Regex = Regex::new("^[a-zA-Z0-9_-]{1,50}$").unwrap();
|
||||
/// Shared so that a deploy's calls reuse connections. A cloud instance instead builds a client
|
||||
/// per request, pinned to the addresses it just validated for that target.
|
||||
static ref REMOTE_CLIENT: reqwest::Client = remote_client_builder().build().unwrap();
|
||||
}
|
||||
|
||||
const PROXY_PREFIX: &str = "/remote_deploy/proxy/";
|
||||
|
||||
pub fn workspaced_service(proxy_body_limit: usize) -> Router {
|
||||
Router::new()
|
||||
.route("/target", get(get_target).post(set_target))
|
||||
.route("/connect", post(connect))
|
||||
.route("/disconnect", post(disconnect))
|
||||
// `{key}` is the caller's `proxy_key`. The session cookie is `SameSite=Lax`, so it rides a
|
||||
// top-level navigation from any site: without a value such a link cannot know, it could
|
||||
// spend the stored token, to run something on the remote or to render its content on this
|
||||
// origin. `Sec-Fetch-Site` is no substitute, since plain-http instances never receive it.
|
||||
.route(
|
||||
"/proxy/{key}/{*rest}",
|
||||
any(proxy).layer(DefaultBodyLimit::max(proxy_body_limit)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A request URI as logs should record it: with the proxy key masked, since that key is what keeps
|
||||
/// a link from spending a stored remote token.
|
||||
pub struct RedactedUri<'a>(pub &'a axum::http::Uri);
|
||||
|
||||
impl std::fmt::Display for RedactedUri<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let Some((head, keyed)) = self.0.path().split_once(PROXY_PREFIX) else {
|
||||
return self.0.fmt(f);
|
||||
};
|
||||
let rest = keyed.find('/').map_or("", |i| &keyed[i..]);
|
||||
write!(f, "{head}{PROXY_PREFIX}***{rest}")?;
|
||||
match self.0.query() {
|
||||
Some(query) => write!(f, "?{query}"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct RemoteDeployTarget {
|
||||
/// Root URL of the remote instance, without `/api`.
|
||||
pub base_url: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
impl RemoteDeployTarget {
|
||||
fn normalized(self) -> Result<Self> {
|
||||
let base_url = self.base_url.trim().trim_end_matches('/').to_string();
|
||||
let parsed = url::Url::parse(&base_url)
|
||||
.map_err(|e| Error::BadRequest(format!("Invalid remote instance URL: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err(Error::BadRequest(
|
||||
"The remote instance URL must be an http(s) URL with a host".to_string(),
|
||||
));
|
||||
}
|
||||
if !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"The remote instance URL must not carry credentials, a query or a fragment"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let workspace_id = self.workspace_id.trim().to_string();
|
||||
if !REMOTE_WORKSPACE_ID.is_match(&workspace_id) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid remote workspace id: {workspace_id}"
|
||||
)));
|
||||
}
|
||||
Ok(Self { base_url, workspace_id })
|
||||
}
|
||||
|
||||
fn api_url(&self, suffix: &str) -> String {
|
||||
format!("{}/api/w/{}/{suffix}", self.base_url, self.workspace_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RemoteDeployConnection {
|
||||
remote_email: String,
|
||||
/// Goes in every proxy URL, see [`workspaced_service`].
|
||||
proxy_key: String,
|
||||
connected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RemoteDeployStatus {
|
||||
target: Option<RemoteDeployTarget>,
|
||||
/// The caller's own connection to `target`.
|
||||
connection: Option<RemoteDeployConnection>,
|
||||
}
|
||||
|
||||
/// A stored token acts on another instance as its owner, with none of the restrictions of the
|
||||
/// credential that reaches it. Only the owner acting directly with full rights may use or replace
|
||||
/// it: a job token runs as whoever a `wm_deployers` member pointed `on_behalf_of` at, and a
|
||||
/// scoped or read-only token was never granted this.
|
||||
fn require_own_credentials(authed: &ApiAuthed) -> Result<()> {
|
||||
if authed.job_id.is_some()
|
||||
|| authed.read_only
|
||||
|| !is_effectively_unscoped(authed.scopes.as_deref())
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Deploying to a remote instance requires your own session or an unscoped token, \
|
||||
not a job, scoped or read-only token"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_target(target: Option<serde_json::Value>) -> Result<Option<RemoteDeployTarget>> {
|
||||
target
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|e| Error::internal_err(format!("reading the remote deploy target: {e}")))
|
||||
}
|
||||
|
||||
fn no_target(w_id: &str) -> Error {
|
||||
Error::BadRequest(format!(
|
||||
"Workspace {w_id} has no remote deploy target. An admin sets it in the workspace \
|
||||
settings, under Dev workspace"
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_target(db: &DB, w_id: &str) -> Result<Option<RemoteDeployTarget>> {
|
||||
let target = sqlx::query_scalar!(
|
||||
"SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
parse_target(target)
|
||||
}
|
||||
|
||||
async fn require_target(db: &DB, w_id: &str) -> Result<RemoteDeployTarget> {
|
||||
load_target(db, w_id).await?.ok_or_else(|| no_target(w_id))
|
||||
}
|
||||
|
||||
fn remote_client_builder() -> reqwest::ClientBuilder {
|
||||
configure_client(reqwest::ClientBuilder::new())
|
||||
.user_agent("windmill/remote-deploy")
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(120))
|
||||
}
|
||||
|
||||
async fn remote_client(target: &RemoteDeployTarget) -> Result<reqwest::Client> {
|
||||
// A workspace admin chose the URL. A self-hosted instance may deploy into its own private
|
||||
// network, as it may reach a private git remote; a cloud workspace must not.
|
||||
if !*CLOUD_HOSTED {
|
||||
return Ok(REMOTE_CLIENT.clone());
|
||||
}
|
||||
validate_url_for_ssrf(&target.base_url)
|
||||
.await?
|
||||
.apply_dns_pinning(remote_client_builder())
|
||||
.build()
|
||||
.map_err(|e| Error::internal_err(format!("building the remote deploy client: {e}")))
|
||||
}
|
||||
|
||||
fn unreachable(target: &RemoteDeployTarget, e: reqwest::Error) -> Error {
|
||||
Error::BadGateway(format!(
|
||||
"Could not reach the remote instance {}: {}",
|
||||
target.base_url,
|
||||
error_source_chain(&e)
|
||||
))
|
||||
}
|
||||
|
||||
/// Responses carrying a `proxy_key` must not be kept by any cache between here and the browser.
|
||||
const NO_STORE: [(header::HeaderName, &str); 1] = [(header::CACHE_CONTROL, "no-store")];
|
||||
|
||||
struct StoredConnection {
|
||||
token: String,
|
||||
remote_email: String,
|
||||
proxy_key: String,
|
||||
connected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// The caller's connection to `target`, if they may still use it.
|
||||
///
|
||||
/// `connect` takes no lock against what clears these rows (a removal from the workspace, a target
|
||||
/// change, a key rotation): writers take those rows in every order, so any lock it held could close
|
||||
/// a deadlock. A row can therefore land after one of them ran, however long its remote call took,
|
||||
/// and is voided here instead, by identity rather than by comparing times: it counts only while
|
||||
/// the target setting and the owner's membership are the very ones it was connected under (see
|
||||
/// `member_since`, `target_changed_at`) or the owner is a superadmin, and while it decrypts. An
|
||||
/// emptied row is a disconnect's.
|
||||
async fn load_connection(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
target: &RemoteDeployTarget,
|
||||
) -> Result<Option<StoredConnection>> {
|
||||
let Some(row) = sqlx::query_as!(
|
||||
StoredConnection,
|
||||
"SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t
|
||||
JOIN workspace_settings s ON s.workspace_id = t.workspace_id
|
||||
WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3
|
||||
AND t.remote_workspace_id = $4 AND t.token <> ''
|
||||
AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at
|
||||
AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id
|
||||
AND u.email = t.email AND u.created_at = t.member_since)
|
||||
OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))",
|
||||
w_id,
|
||||
email,
|
||||
&target.base_url,
|
||||
&target.workspace_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
match decrypt(&build_crypt(db, w_id).await?, row.token) {
|
||||
Ok(token) => Ok(Some(StoredConnection { token, ..row })),
|
||||
Err(e) => {
|
||||
tracing::warn!("remote deploy token of {email} in {w_id} does not decrypt: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_target(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> Result<(
|
||||
[(header::HeaderName, &'static str); 1],
|
||||
Json<RemoteDeployStatus>,
|
||||
)> {
|
||||
let target = load_target(&db, &w_id).await?;
|
||||
// The connection carries the proxy key. A credential that may not use the proxy (see
|
||||
// `require_own_credentials`) must not read it either: it could hand its owner a working link.
|
||||
let connection = match &target {
|
||||
Some(target) if require_own_credentials(&authed).is_ok() => {
|
||||
load_connection(&db, &w_id, &authed.email, target)
|
||||
.await?
|
||||
.map(|c| RemoteDeployConnection {
|
||||
remote_email: c.remote_email,
|
||||
proxy_key: c.proxy_key,
|
||||
connected_at: c.connected_at,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok((NO_STORE, Json(RemoteDeployStatus { target, connection })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetRemoteDeployTarget {
|
||||
target: Option<RemoteDeployTarget>,
|
||||
}
|
||||
|
||||
async fn set_target(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(request): Json<SetRemoteDeployTarget>,
|
||||
) -> Result<String> {
|
||||
if !cfg!(feature = "enterprise") {
|
||||
return Err(Error::BadRequest(
|
||||
"Deploying to another instance is only available on Windmill Enterprise Edition"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
// Everyone who connects afterwards hands their token to this URL.
|
||||
require_own_credentials(&authed)?;
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let target = request
|
||||
.target
|
||||
.map(RemoteDeployTarget::normalized)
|
||||
.transpose()?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let (base_url, remote_workspace_id) = match &target {
|
||||
Some(t) => (Some(t.base_url.as_str()), Some(t.workspace_id.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_remote_deploy_target",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
None,
|
||||
Some(
|
||||
[
|
||||
("base_url", base_url.unwrap_or("")),
|
||||
("remote_workspace_id", remote_workspace_id.unwrap_or("")),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let target_json = target
|
||||
.as_ref()
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,
|
||||
remote_deploy_target_changed_at = CASE
|
||||
WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()
|
||||
ELSE remote_deploy_target_changed_at END
|
||||
WHERE workspace_id = $2",
|
||||
target_json,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A token is only ever sent to the target it was granted for, so the ones for any other
|
||||
// target are dead credentials.
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE workspace_id = $1
|
||||
AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)",
|
||||
&w_id,
|
||||
base_url,
|
||||
remote_workspace_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(match target {
|
||||
Some(t) => format!(
|
||||
"Workspace {w_id} deploys to {} on {}",
|
||||
t.workspace_id, t.base_url
|
||||
),
|
||||
None => format!("Removed the remote deploy target of workspace {w_id}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConnectRequest {
|
||||
token: String,
|
||||
/// The target the caller got the token for, as the drawer showed it.
|
||||
target: RemoteDeployTarget,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RemoteWhoami {
|
||||
email: String,
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
authed: ApiAuthed,
|
||||
Tokened { token: credential }: Tokened,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(request): Json<ConnectRequest>,
|
||||
) -> Result<(
|
||||
[(header::HeaderName, &'static str); 1],
|
||||
Json<RemoteDeployConnection>,
|
||||
)> {
|
||||
require_own_credentials(&authed)?;
|
||||
// Read before the remote call, which can take long enough for any of it to change: the row is
|
||||
// bound to the target version and the membership seen here (see `load_connection`), and
|
||||
// stamped with when this connect started, so that a disconnect or a newer connect made while
|
||||
// it runs keeps it from landing.
|
||||
let start = sqlx::query!(
|
||||
r#"SELECT clock_timestamp() AS "started_at!", s.remote_deploy_target,
|
||||
s.remote_deploy_target_changed_at,
|
||||
(SELECT u.created_at FROM usr u
|
||||
WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since
|
||||
FROM workspace_settings s WHERE s.workspace_id = $1"#,
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| no_target(&w_id))?;
|
||||
let target = parse_target(start.remote_deploy_target)?.ok_or_else(|| no_target(&w_id))?;
|
||||
// A token is only ever sent to the instance it was meant for. Without this, re-pointing the
|
||||
// target between the user getting a token and posting it here would hand it to the new one;
|
||||
// after this check the token still goes to `target` only, never to what the setting became.
|
||||
let requested = request.target.normalized()?;
|
||||
if requested.base_url != target.base_url || requested.workspace_id != target.workspace_id {
|
||||
return Err(Error::BadRequest(
|
||||
"The remote deploy target changed since you started connecting; reload and connect \
|
||||
again"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let token = request.token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(Error::BadRequest("The token is empty".to_string()));
|
||||
}
|
||||
|
||||
let response = remote_client(&target)
|
||||
.await?
|
||||
.get(target.api_url("users/whoami"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| unreachable(&target, e))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{} did not accept this token for workspace {} ({status}): {body}",
|
||||
target.base_url, target.workspace_id
|
||||
)));
|
||||
}
|
||||
let whoami: RemoteWhoami = response.json().await.map_err(|e| {
|
||||
Error::BadGateway(format!(
|
||||
"{} did not answer like a Windmill instance: {}",
|
||||
target.base_url,
|
||||
error_source_chain(&e)
|
||||
))
|
||||
})?;
|
||||
|
||||
let proxy_key = rd_string(32);
|
||||
let mc = build_crypt(&db, &w_id).await?;
|
||||
// No lock against removals, target changes or key rotations: `load_connection` voids a row
|
||||
// that lands after one of them. A disconnect or a newer connect stamps the row itself, which
|
||||
// this one then leaves alone. Without a membership to bind to, the row is bound to the account
|
||||
// through the credential making this request, which deleting the account removes (except
|
||||
// through `leave_instance`, which leaves its tokens): the address could otherwise be deleted
|
||||
// and taken by a new account while the remote call runs, and the foreign key would accept it.
|
||||
let mut tx = db.begin().await?;
|
||||
let connected_at = sqlx::query_scalar!(
|
||||
"INSERT INTO remote_deploy_token
|
||||
(workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,
|
||||
connected_at, member_since, target_changed_at)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
WHERE $9::timestamptz IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)
|
||||
ON CONFLICT (workspace_id, email) DO UPDATE SET
|
||||
base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,
|
||||
token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,
|
||||
proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,
|
||||
member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at
|
||||
WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at
|
||||
RETURNING connected_at",
|
||||
&w_id,
|
||||
&authed.email,
|
||||
&target.base_url,
|
||||
&target.workspace_id,
|
||||
encrypt(&mc, token),
|
||||
&whoami.email,
|
||||
&proxy_key,
|
||||
start.started_at,
|
||||
start.member_since,
|
||||
start.remote_deploy_target_changed_at,
|
||||
hash_token(&credential)
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(match start.member_since {
|
||||
Some(_) => "This connect was not saved: you disconnected or connected again while it \
|
||||
was running"
|
||||
.to_string(),
|
||||
None => format!(
|
||||
"This connect was not saved: you disconnected, connected again or signed out \
|
||||
while it was running. Connecting workspace {w_id} without being one of its \
|
||||
members needs a session or an API token of this instance"
|
||||
),
|
||||
})
|
||||
})?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.remote_deploy_connect",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&whoami.email),
|
||||
Some([("base_url", target.base_url.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((
|
||||
NO_STORE,
|
||||
Json(RemoteDeployConnection { remote_email: whoami.email, proxy_key, connected_at }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn disconnect(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> Result<String> {
|
||||
require_own_credentials(&authed)?;
|
||||
let mut tx = db.begin().await?;
|
||||
// Emptied rather than deleted, and stamped, even with no connection yet: a connect that started
|
||||
// before this must not bring one back when it lands (see `connect`).
|
||||
sqlx::query!(
|
||||
"INSERT INTO remote_deploy_token
|
||||
(workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,
|
||||
connected_at)
|
||||
VALUES ($1, $2, '', '', '', '', '', clock_timestamp())
|
||||
ON CONFLICT (workspace_id, email) DO UPDATE SET
|
||||
token = '', proxy_key = '', connected_at = clock_timestamp()",
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.remote_deploy_disconnect",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(format!(
|
||||
"Disconnected from the remote deploy target of {w_id}"
|
||||
))
|
||||
}
|
||||
|
||||
/// The remote URL for the request path after the proxy prefix and key, as the client sent it.
|
||||
///
|
||||
/// `url` rewrites a path while parsing it: it resolves dot segments (`%2e` spellings included)
|
||||
/// and turns backslashes into slashes. Either would reach a route other than the one this
|
||||
/// instance authorized — outside the remote workspace, or a route its read-only check does not
|
||||
/// recognize — so a path the parser would change is refused rather than forwarded.
|
||||
fn forwarded_url(
|
||||
target: &RemoteDeployTarget,
|
||||
original_path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<url::Url> {
|
||||
let suffix = original_path
|
||||
.split_once(PROXY_PREFIX)
|
||||
.and_then(|(_, keyed)| keyed.split_once('/'))
|
||||
.map(|(_, suffix)| suffix)
|
||||
.unwrap_or_default();
|
||||
let invalid = || Error::BadRequest(format!("Invalid remote deploy path: {suffix}"));
|
||||
let base_path = url::Url::parse(&target.base_url)
|
||||
.map_err(|_| invalid())?
|
||||
.path()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let mut url = url::Url::parse(&target.api_url(suffix)).map_err(|_| invalid())?;
|
||||
let expected_path = format!("{base_path}/api/w/{}/{suffix}", target.workspace_id);
|
||||
if suffix.is_empty() || url.path() != expected_path {
|
||||
return Err(invalid());
|
||||
}
|
||||
url.set_query(query);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn proxy(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, key, _rest)): Path<(String, String, String)>,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response> {
|
||||
require_own_credentials(&authed)?;
|
||||
let target = require_target(&db, &w_id).await?;
|
||||
let url = forwarded_url(&target, uri.path(), uri.query())?;
|
||||
let stored = load_connection(&db, &w_id, &authed.email, &target)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"Connect to {} with your own token before deploying there",
|
||||
target.base_url
|
||||
))
|
||||
})?;
|
||||
if !constant_time_eq::constant_time_eq(key.as_bytes(), stored.proxy_key.as_bytes()) {
|
||||
return Err(Error::BadRequest(
|
||||
"This remote deploy link is not yours or is out of date; reload the page".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Only what describes the payload: the caller's cookie and token belong to this instance.
|
||||
let mut request = remote_client(&target)
|
||||
.await?
|
||||
.request(method, url)
|
||||
.bearer_auth(stored.token)
|
||||
.body(body);
|
||||
for name in [header::CONTENT_TYPE, header::ACCEPT] {
|
||||
if let Some(value) = headers.get(&name) {
|
||||
request = request.header(name, value.clone());
|
||||
}
|
||||
}
|
||||
let response = request.send().await.map_err(|e| unreachable(&target, e))?;
|
||||
|
||||
let status = response.status();
|
||||
// Passed through, a 401 would read as this instance's session having expired and log the
|
||||
// user out of it.
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(Error::BadGateway(format!(
|
||||
"{} rejected your stored token: {body}",
|
||||
target.base_url
|
||||
)));
|
||||
}
|
||||
if status.is_redirection() {
|
||||
let location = response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|l| l.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
return Err(Error::BadGateway(format!(
|
||||
"{} redirected to {location}. Set the remote deploy target to the URL it redirects to",
|
||||
target.base_url
|
||||
)));
|
||||
}
|
||||
// The body is the remote's and may be anything its users stored (an uploaded file, a job
|
||||
// result typed `text/html`); served from this origin it must never render as a page here.
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||
.header(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"sandbox; default-src 'none'",
|
||||
);
|
||||
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
|
||||
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
builder
|
||||
.body(Body::from_stream(response.bytes_stream()))
|
||||
.map_err(|e| Error::internal_err(format!("building the proxied response: {e}")))
|
||||
}
|
||||
|
||||
/// Move the stored remote tokens to a new workspace key. A token that no longer decrypts is
|
||||
/// dropped: its owner connects again. A disconnect's emptied row is kept, as the stamp that keeps
|
||||
/// an older connect from landing.
|
||||
pub(crate) async fn reencrypt_tokens(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
w_id: &str,
|
||||
old: &MagicCrypt256,
|
||||
new: &MagicCrypt256,
|
||||
) -> Result<()> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT email, token FROM remote_deploy_token
|
||||
WHERE workspace_id = $1 AND token <> '' FOR UPDATE",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
for row in rows {
|
||||
match decrypt(old, row.token) {
|
||||
Ok(plain) => {
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET token = $1
|
||||
WHERE workspace_id = $2 AND email = $3",
|
||||
encrypt(new, &plain),
|
||||
w_id,
|
||||
row.email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Err(_) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2",
|
||||
w_id,
|
||||
row.email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn forwarded_url_refuses_paths_the_parser_rewrites() {
|
||||
let target = RemoteDeployTarget {
|
||||
base_url: "https://prod.example.com/windmill".to_string(),
|
||||
workspace_id: "prod".to_string(),
|
||||
};
|
||||
let prefix = "/api/w/dev/remote_deploy/proxy/someKey";
|
||||
let url = forwarded_url(
|
||||
&target,
|
||||
&format!("{prefix}/scripts/get/p/f/team/my%20script"),
|
||||
Some("with_starred_info=true"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://prod.example.com/windmill/api/w/prod/scripts/get/p/f/team/my%20script?with_starred_info=true"
|
||||
);
|
||||
for path in [
|
||||
format!("{prefix}/../../users/list"),
|
||||
format!("{prefix}/%2e%2e/%2e%2e/users/list"),
|
||||
format!("{prefix}/jobs\\run_wait_result\\p/f/team/action"),
|
||||
prefix.to_string(),
|
||||
] {
|
||||
assert!(
|
||||
forwarded_url(&target, &path, None).is_err(),
|
||||
"{path} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_uri_masks_the_proxy_key() {
|
||||
let proxied: axum::http::Uri = "/api/w/dev/remote_deploy/proxy/secretKey/flows/get/f/a?x=1"
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
RedactedUri(&proxied).to_string(),
|
||||
"/api/w/dev/remote_deploy/proxy/***/flows/get/f/a?x=1"
|
||||
);
|
||||
let other: axum::http::Uri = "/api/w/dev/flows/list?x=1".parse().unwrap();
|
||||
assert_eq!(RedactedUri(&other).to_string(), "/api/w/dev/flows/list?x=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_normalization() {
|
||||
let target = RemoteDeployTarget {
|
||||
base_url: " https://prod.example.com/ ".to_string(),
|
||||
workspace_id: " prod ".to_string(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(target.base_url, "https://prod.example.com");
|
||||
assert_eq!(
|
||||
target.api_url("flows/create"),
|
||||
"https://prod.example.com/api/w/prod/flows/create"
|
||||
);
|
||||
|
||||
for (base_url, workspace_id) in [
|
||||
("ftp://prod.example.com", "prod"),
|
||||
("https://user:pass@prod.example.com", "prod"),
|
||||
("https://prod.example.com?token=x", "prod"),
|
||||
("https://prod.example.com", "prod/../admins"),
|
||||
] {
|
||||
assert!(
|
||||
RemoteDeployTarget {
|
||||
base_url: base_url.to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
}
|
||||
.normalized()
|
||||
.is_err(),
|
||||
"{base_url} {workspace_id} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5960,6 +5960,13 @@ async fn set_encryption_key(
|
||||
&new_encryption_key,
|
||||
)
|
||||
.await?;
|
||||
crate::remote_deploy::reencrypt_tokens(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&previous_encryption_key,
|
||||
&new_encryption_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -10143,13 +10150,25 @@ async fn leave_workspace(
|
||||
&format!("u/{}", authed.username),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
let left = sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.rows_affected();
|
||||
// A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop
|
||||
// its connection.
|
||||
if left > 0 {
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
&authed.email,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
@@ -841,6 +841,14 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating variable table");
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
|
||||
@@ -4187,6 +4187,100 @@ paths:
|
||||
deploy_to:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/remote_deploy/target:
|
||||
get:
|
||||
summary: get the workspace's deploy target on another instance, and the caller's connection to it
|
||||
description: |
|
||||
Once connected, deploy calls aimed at the target go through
|
||||
`/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`, which forwards any method to
|
||||
`{base_url}/api/w/{target workspace}/{route}` with the caller's stored token.
|
||||
operationId: getRemoteDeployTarget
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: remote deploy target and the caller's connection to it
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RemoteDeployStatus"
|
||||
post:
|
||||
summary: set or clear the workspace's deploy target on another instance
|
||||
description: Changing the target drops every token stored for the previous one.
|
||||
operationId: setRemoteDeployTarget
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/RemoteDeployTarget"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/remote_deploy/connect:
|
||||
post:
|
||||
summary: store the caller's own token for the remote deploy target
|
||||
description: |
|
||||
The token is checked against the target's whoami before it is stored. `target` names the
|
||||
target the token was obtained for, and the request is refused if the workspace now points
|
||||
elsewhere, so a token is never sent to an instance it was not meant for.
|
||||
operationId: connectRemoteDeploy
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
target:
|
||||
$ref: "#/components/schemas/RemoteDeployTarget"
|
||||
required:
|
||||
- token
|
||||
- target
|
||||
responses:
|
||||
"200":
|
||||
description: the stored connection
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RemoteDeployConnection"
|
||||
|
||||
/w/{workspace}/remote_deploy/disconnect:
|
||||
post:
|
||||
summary: forget the caller's token for the remote deploy target
|
||||
operationId: disconnectRemoteDeploy
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/is_premium:
|
||||
get:
|
||||
summary: get if workspace is premium
|
||||
@@ -36532,6 +36626,44 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/GitSyncObjectType"
|
||||
|
||||
RemoteDeployTarget:
|
||||
type: object
|
||||
properties:
|
||||
base_url:
|
||||
type: string
|
||||
description: root URL of the remote Windmill instance, without /api
|
||||
workspace_id:
|
||||
type: string
|
||||
description: workspace on the remote instance that deploys land in
|
||||
required:
|
||||
- base_url
|
||||
- workspace_id
|
||||
|
||||
RemoteDeployConnection:
|
||||
type: object
|
||||
properties:
|
||||
remote_email:
|
||||
type: string
|
||||
description: identity the stored token has on the remote instance
|
||||
proxy_key:
|
||||
type: string
|
||||
description: goes in every proxy URL, `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`
|
||||
connected_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- remote_email
|
||||
- proxy_key
|
||||
- connected_at
|
||||
|
||||
RemoteDeployStatus:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/RemoteDeployTarget"
|
||||
connection:
|
||||
$ref: "#/components/schemas/RemoteDeployConnection"
|
||||
|
||||
WorkspaceDefaultScripts:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -731,6 +731,12 @@ pub async fn run_server(
|
||||
path_autocomplete::workspaced_service(),
|
||||
)
|
||||
.nest("/raw_apps", raw_apps::workspaced_service())
|
||||
.nest(
|
||||
"/remote_deploy",
|
||||
windmill_api_workspaces::remote_deploy::workspaced_service(
|
||||
request_size_limit * 5,
|
||||
),
|
||||
)
|
||||
// CORS so the opaque-origin app iframe can read
|
||||
// resources/list, resources/type/* with a scoped token.
|
||||
.nest(
|
||||
|
||||
@@ -14,6 +14,7 @@ use axum::response::Response as AxumResponse;
|
||||
use hyper::Response;
|
||||
use tower_http::trace::{MakeSpan, OnFailure, OnResponse};
|
||||
use uuid::Uuid;
|
||||
use windmill_api_workspaces::remote_deploy::RedactedUri;
|
||||
use windmill_common::log_context::{with_log_context, LogContext};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -86,7 +87,7 @@ impl<B> MakeSpan<B> for MyMakeSpan {
|
||||
tracing::error_span!(
|
||||
"request",
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
uri = %RedactedUri(request.uri()),
|
||||
username = field::Empty,
|
||||
workspace_id = field::Empty,
|
||||
traceId = tracing_id,
|
||||
@@ -113,7 +114,7 @@ pub async fn log_context_middleware(request: Request, next: Next) -> AxumRespons
|
||||
|
||||
let ctx = LogContext {
|
||||
method: Some(request.method().to_string()),
|
||||
uri: Some(request.uri().to_string()),
|
||||
uri: Some(RedactedUri(request.uri()).to_string()),
|
||||
trace_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -20,7 +20,8 @@ use crate::{
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`). System-token labels are load-bearing —
|
||||
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`, `remote-deploy:*`).
|
||||
/// System-token labels are load-bearing —
|
||||
/// session cleanup, super_admin propagation, expiry notifications and username overrides
|
||||
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
|
||||
/// a user token.
|
||||
@@ -48,6 +49,7 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with("impersonation:")
|
||||
&& !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX)
|
||||
&& !l.starts_with(REMOTE_DEPLOY_TOKEN_LABEL_PREFIX)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +75,14 @@ pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
|
||||
/// `/users/tokens/create`.
|
||||
pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:";
|
||||
|
||||
/// Label prefix, followed by the host of the instance deploying with it, of the token the
|
||||
/// `/user/remote_deploy_authorize` page mints for another Windmill instance. Reserved in
|
||||
/// [`is_user_token`], whose SQL and frontend mirrors spell it out: it is renewed by connecting
|
||||
/// again from the deploying instance, which reports this one rejecting it, so an expiry email or
|
||||
/// alert here points at the wrong place. Not in [`is_server_minted_label`], since the page mints it
|
||||
/// through `/users/tokens/create`.
|
||||
pub const REMOTE_DEPLOY_TOKEN_LABEL_PREFIX: &str = "remote-deploy:";
|
||||
|
||||
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
|
||||
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
|
||||
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
|
||||
@@ -997,6 +1007,7 @@ mod tests {
|
||||
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
|
||||
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
|
||||
assert!(!is_user_token(Some("cli-login:admin")));
|
||||
assert!(!is_user_token(Some("remote-deploy:windmill.example.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -46,6 +46,52 @@ Symbols, not line numbers, are cited: they drift less.
|
||||
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
|
||||
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
|
||||
token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes.
|
||||
- **A remote deploy token is a credential for another instance**, held per account and workspace
|
||||
(`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`).
|
||||
Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or
|
||||
renames an account takes the token along and a recycled address cannot inherit it; removal from
|
||||
a workspace deletes it explicitly (`delete_workspace_user_internal`, both `leave_workspace`). The
|
||||
row records the target it was granted for, and
|
||||
`remote_deploy::proxy` only sends it to a target still matching the workspace setting, so
|
||||
re-pointing the setting cannot redirect anyone's token to a URL of the admin's choosing.
|
||||
`require_own_credentials` refuses job, scoped and read-only tokens (on `set_target` too): the
|
||||
stored token carries none of their restrictions. The proxy turns the local session into a remote
|
||||
bearer credential, so every ambient-cookie vector becomes one on the remote: its URL carries the
|
||||
row's random `proxy_key` (a link riding the `SameSite=Lax` cookie cannot know it; a header would
|
||||
do, but the frontend's only per-call hook is the global `OpenAPI.HEADERS`, whose mere presence
|
||||
switches every download to in-memory blobs). The key is served `no-store`, withheld from the
|
||||
credentials `require_own_credentials` refuses, and masked in this instance's own request logs
|
||||
(`RedactedUri`, used by the request span and the log context); a reverse proxy in front still
|
||||
writes the full path to its access log. `connect` names the target the token was obtained for
|
||||
and is refused, before the token is sent anywhere, if the workspace now points elsewhere; the
|
||||
token only ever goes to that target. It takes no lock against what clears these rows (a removal
|
||||
from the workspace, a target change, a key rotation): their writers take the account, membership,
|
||||
key and settings rows in every order, so any lock held there could close a deadlock. A row can
|
||||
therefore land after one of them ran, however long its remote call took, and every read
|
||||
(`load_connection`) voids it instead, by identity rather than by comparing times: the connect
|
||||
records, before its remote call, the target setting's version (`remote_deploy_target_changed_at`)
|
||||
and the `created_at` of the owner's membership, and the row counts only while both are still
|
||||
the same (or the owner is a superadmin) and it decrypts. So a target set A → B → A, or a
|
||||
removal and re-add, voids it whatever the clocks say. A superadmin with no membership is bound
|
||||
through the credential making the request instead: the insert requires its `token` row to
|
||||
still exist, and `delete_user`, offboarding and SCIM removal delete an account's tokens, so an
|
||||
address deleted that way and re-created during the call does not inherit the connection (a JWT,
|
||||
having no row, cannot connect a workspace its owner is not a member of). `leave_instance`
|
||||
deletes only the `password` row, leaving the tokens and the memberships, which is not covered.
|
||||
Neither is a member's connect across an account re-creation: an address is not expected to pass
|
||||
to another person. A disconnect empties and stamps the row
|
||||
(inserting one if needed) rather than deleting it, `connected_at` is when a connect started,
|
||||
and the connect's upsert leaves a row stamped after that alone, so an older connect cannot undo
|
||||
a disconnect or a newer connect. Connecting by redirect: the remote's
|
||||
`/user/remote_deploy_authorize` page
|
||||
mints a token bound to the one remote workspace (`remote-deploy:<source host>`, a label
|
||||
reserved in `is_user_token` so its expiry emails nobody) only on an
|
||||
explicit Authorize, only for a callback whose path is `/remote_deploy/callback`, and refuses to
|
||||
render inside a frame; the token travels in the fragment, and the callback checks a single-use
|
||||
`state` the drawer stored, so no other page can plant a token as the user's. The proxy also refuses a path the URL parser would rewrite,
|
||||
serves every response under `CSP: sandbox` + `nosniff`, forwards only the method, query, body,
|
||||
content-type and accept — never this instance's cookie or token — and turns the target's 401
|
||||
into a 502, because the browser logs the user out of *this* instance on an unhandled 401.
|
||||
- **`login_type`** (`password` table) is a free-form `VARCHAR(50)`. Password login and password
|
||||
reset require `login_type = 'password'`; `set_password` also accepts `pending_oauth` and turns
|
||||
the account into a `password` one in the same statement (an account created ahead of its owner
|
||||
|
||||
@@ -13,8 +13,14 @@
|
||||
ResourceService,
|
||||
ScheduleService,
|
||||
UserService,
|
||||
WorkspaceService
|
||||
WorkspaceService,
|
||||
type RemoteDeployStatus
|
||||
} from '$lib/gen'
|
||||
import { remoteDeployLabel, remoteDeployWorkspace } from '$lib/remoteDeploy'
|
||||
import RemoteDeployConnect from './RemoteDeployConnect.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import { getAllModules } from './flows/flowExplorer'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
@@ -67,11 +73,22 @@
|
||||
|
||||
let canSeeTarget: 'yes' | 'cant-deploy-to-workspace' | 'cant-see-all-deps' | undefined =
|
||||
$state(undefined)
|
||||
/** What the target answered when it refused, for a destination whose credential can go stale. */
|
||||
let targetError: string | undefined = $state(undefined)
|
||||
|
||||
// The two destinations a workspace can have: its parent on this instance, and a workspace on
|
||||
// another instance. Both are set by an admin, so a workspace may have either, both or neither.
|
||||
type Destination = 'parent' | 'remote'
|
||||
let parentWorkspace: string | undefined = $state(undefined)
|
||||
let remoteStatus = $state<RemoteDeployStatus | undefined>(undefined)
|
||||
let destination: Destination | undefined = $state(undefined)
|
||||
let remoteTarget = $derived(remoteStatus?.target)
|
||||
let isRemote = $derived(destination === 'remote')
|
||||
|
||||
type Dependency = { kind: Kind; path: string; include: boolean }
|
||||
let dependencies: Dependency[] | undefined = $state<Dependency[] | undefined>(undefined)
|
||||
|
||||
const allAlreadyExists: { [key: string]: boolean } = $state({})
|
||||
let allAlreadyExists: { [key: string]: boolean } = $state({})
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
||||
let notSet: boolean | undefined = $state(undefined)
|
||||
@@ -120,17 +137,37 @@
|
||||
}
|
||||
|
||||
async function reload(path: string) {
|
||||
const target = workspaceToDeployTo
|
||||
// Everything below is about this one target; switching destination starts its own pass, and
|
||||
// the one it replaced must not write its answers into the new one's state.
|
||||
const stillCurrent = () => target === workspaceToDeployTo
|
||||
canSeeTarget = undefined
|
||||
targetError = undefined
|
||||
dependencies = undefined
|
||||
allAlreadyExists = {}
|
||||
deploymentStatus = {}
|
||||
targetOnBehalfOfInfo = {}
|
||||
onBehalfOfChoice = {}
|
||||
customOnBehalfOf = {}
|
||||
try {
|
||||
if (!$superadmin) {
|
||||
const targetUser = await UserService.whoami({ workspace: workspaceToDeployTo! })
|
||||
canPreserveOnBehalfOf =
|
||||
targetUser.is_admin || targetUser.groups?.includes('wm_deployers') || false
|
||||
} else {
|
||||
canPreserveOnBehalfOf = true
|
||||
// Being superadmin here says nothing about the other instance, so a remote target is
|
||||
// always asked.
|
||||
let canPreserve = true
|
||||
if (!$superadmin || isRemote) {
|
||||
const targetUser = await UserService.whoami({ workspace: target! })
|
||||
canPreserve =
|
||||
targetUser.is_admin ||
|
||||
targetUser.is_super_admin ||
|
||||
targetUser.groups?.includes('wm_deployers') ||
|
||||
false
|
||||
}
|
||||
if (!stillCurrent()) return
|
||||
canPreserveOnBehalfOf = canPreserve
|
||||
canSeeTarget = 'yes'
|
||||
} catch {
|
||||
} catch (e: any) {
|
||||
if (!stillCurrent()) return
|
||||
canSeeTarget = 'cant-deploy-to-workspace'
|
||||
targetError = e?.body ?? e?.message
|
||||
canPreserveOnBehalfOf = false
|
||||
return
|
||||
}
|
||||
@@ -139,6 +176,7 @@
|
||||
try {
|
||||
allDeps = await getDependencies(kind, path)
|
||||
} catch {
|
||||
if (!stillCurrent()) return
|
||||
canSeeTarget = 'cant-see-all-deps'
|
||||
return
|
||||
}
|
||||
@@ -150,10 +188,9 @@
|
||||
}
|
||||
})
|
||||
for (const dep of sortedSet) {
|
||||
allAlreadyExists[computeStatusPath(dep.kind, dep.path)] = await checkAlreadyExists(
|
||||
dep.kind,
|
||||
dep.path
|
||||
)
|
||||
const exists = await checkAlreadyExists(dep.kind, dep.path)
|
||||
if (!stillCurrent()) return
|
||||
allAlreadyExists[computeStatusPath(dep.kind, dep.path)] = exists
|
||||
}
|
||||
dependencies = sortedSet.map((x) => ({
|
||||
...x,
|
||||
@@ -169,26 +206,17 @@
|
||||
['flow', 'script', 'app', 'trigger'].includes(d.kind)
|
||||
)) {
|
||||
const key = computeStatusPath(dep.kind, dep.path)
|
||||
let source: string | undefined
|
||||
let targetValue: string | undefined
|
||||
try {
|
||||
sourceOnBehalfOfInfo[key] = await getOnBehalfOf(
|
||||
dep.kind,
|
||||
dep.path,
|
||||
$workspaceStore!,
|
||||
additionalInformation
|
||||
)
|
||||
} catch {
|
||||
sourceOnBehalfOfInfo[key] = undefined
|
||||
}
|
||||
source = await getOnBehalfOf(dep.kind, dep.path, $workspaceStore!, additionalInformation)
|
||||
} catch {}
|
||||
try {
|
||||
targetOnBehalfOfInfo[key] = await getOnBehalfOf(
|
||||
dep.kind,
|
||||
dep.path,
|
||||
workspaceToDeployTo!,
|
||||
additionalInformation
|
||||
)
|
||||
} catch {
|
||||
targetOnBehalfOfInfo[key] = undefined
|
||||
}
|
||||
targetValue = await getOnBehalfOf(dep.kind, dep.path, target!, additionalInformation)
|
||||
} catch {}
|
||||
if (!stillCurrent()) return
|
||||
sourceOnBehalfOfInfo[key] = source
|
||||
targetOnBehalfOfInfo[key] = targetValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,25 +350,29 @@
|
||||
return checkItemExists(kind, path, workspaceToDeployTo!, additionalInformation)
|
||||
}
|
||||
|
||||
const deploymentStatus: Record<
|
||||
let deploymentStatus: Record<
|
||||
string,
|
||||
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
|
||||
> = $state({})
|
||||
|
||||
async function deploy(kind: Kind, path: string) {
|
||||
const statusPath = computeStatusPath(kind, path)
|
||||
const target = workspaceToDeployTo
|
||||
deploymentStatus[statusPath] = { status: 'loading' }
|
||||
|
||||
const result = await deployItem({
|
||||
kind,
|
||||
path,
|
||||
workspaceFrom: $workspaceStore!,
|
||||
workspaceTo: workspaceToDeployTo!,
|
||||
workspaceTo: target!,
|
||||
additionalInformation,
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind),
|
||||
onBehalfOfPrincipal: getOnBehalfOfPermissionedAsForDeploy(statusPath, kind)
|
||||
onBehalfOfPrincipal: getOnBehalfOfPermissionedAsForDeploy(statusPath, kind),
|
||||
targetBaseUrl: isRemote ? remoteTarget?.base_url : undefined
|
||||
})
|
||||
|
||||
// The statuses on screen are the current destination's; this deploy went to `target`.
|
||||
if (target !== workspaceToDeployTo) return
|
||||
if (result.success) {
|
||||
allAlreadyExists[statusPath] = true
|
||||
deploymentStatus[statusPath] = { status: 'deployed' }
|
||||
@@ -376,13 +408,45 @@
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDestinations() {
|
||||
const workspace = $workspaceStore!
|
||||
const [deployTo, remote] = await Promise.all([
|
||||
WorkspaceService.getDeployTo({ workspace }),
|
||||
WorkspaceService.getRemoteDeployTarget({ workspace }).catch(() => undefined)
|
||||
])
|
||||
if (workspace !== $workspaceStore) return
|
||||
parentWorkspace = deployTo.deploy_to
|
||||
remoteStatus = remote
|
||||
// Keep the user's pick across a reload, but only while this workspace still has it: the
|
||||
// drawer stays mounted when the workspace is switched.
|
||||
const available = {
|
||||
parent: parentWorkspace != undefined,
|
||||
remote: remote?.target != undefined
|
||||
}
|
||||
if (!destination || !available[destination]) {
|
||||
destination = available.parent ? 'parent' : available.remote ? 'remote' : undefined
|
||||
}
|
||||
notSet = destination == undefined
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
WorkspaceService.getDeployTo({ workspace: $workspaceStore! }).then((x) => {
|
||||
workspaceToDeployTo = x.deploy_to
|
||||
if (x.deploy_to == undefined) {
|
||||
notSet = true
|
||||
}
|
||||
})
|
||||
$workspaceStore && untrack(() => loadDestinations())
|
||||
})
|
||||
|
||||
// Until the caller has a token for the remote instance there is nothing to deploy against, so
|
||||
// the target stays unset and the drawer's Deploy buttons stay disabled.
|
||||
let destinationWorkspace = $derived(
|
||||
destination === 'parent'
|
||||
? parentWorkspace
|
||||
: isRemote && remoteStatus?.connection
|
||||
? remoteDeployWorkspace($workspaceStore!, remoteStatus.connection)
|
||||
: undefined
|
||||
)
|
||||
let destinationLabel = $derived(
|
||||
isRemote && remoteTarget ? remoteDeployLabel(remoteTarget) : (parentWorkspace ?? '')
|
||||
)
|
||||
$effect(() => {
|
||||
workspaceToDeployTo = destinationWorkspace
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -453,8 +517,9 @@
|
||||
>Deploy to staging/prod from the web UI is only available with an enterprise license</Alert
|
||||
>
|
||||
{:else if notSet == true}
|
||||
<Alert type="error" title="Staging/Prod deploy not set up"
|
||||
>As an admin, go to Settings {'->'} Workspace {'->'} Dev workspace</Alert
|
||||
<Alert type="error" title="Deploy destination not set up"
|
||||
>As an admin, go to Settings {'->'} Workspace {'->'} Dev workspace, to pair this workspace with a
|
||||
prod workspace on this instance or point it at a workspace on another instance</Alert
|
||||
>
|
||||
{:else}
|
||||
<Alert type="info" title="Shareable page"
|
||||
@@ -463,22 +528,62 @@
|
||||
>
|
||||
|
||||
<h3 class="mb-2 mt-8"
|
||||
>Destination Workspace <Tooltip
|
||||
>Workspace to deploy to is set in the workspace settings</Tooltip
|
||||
>Destination <Tooltip
|
||||
>Where this workspace deploys is set in the workspace settings</Tooltip
|
||||
></h3
|
||||
>
|
||||
<input class="max-w-xs" type="text" disabled value={workspaceToDeployTo} />
|
||||
{#if parentWorkspace && remoteTarget}
|
||||
<ToggleButtonGroup
|
||||
selected={destination}
|
||||
onSelected={(v) => (destination = v as Destination)}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="parent"
|
||||
label={parentWorkspace ?? ''}
|
||||
tooltip="Workspace this one deploys into on this instance"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="remote"
|
||||
label={remoteDeployLabel(remoteTarget!)}
|
||||
tooltip="Workspace on another instance"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{:else}
|
||||
<TextInput value={destinationLabel} inputProps={{ disabled: true }} class="max-w-xs" />
|
||||
{/if}
|
||||
|
||||
{#if isRemote && remoteTarget}
|
||||
<div class="mt-3">
|
||||
<RemoteDeployConnect
|
||||
workspace={$workspaceStore!}
|
||||
target={remoteTarget}
|
||||
connection={remoteStatus?.connection}
|
||||
returnTo={kind === 'trigger'
|
||||
? undefined
|
||||
: `${base}/deploy/${kind}/${initialPath}?workspace=${encodeURIComponent($workspaceStore!)}`}
|
||||
onChange={loadDestinations}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if workspaceToDeployTo}
|
||||
<ParentWorkspaceProtectionAlert
|
||||
parentWorkspaceId={workspaceToDeployTo}
|
||||
displayName={destinationLabel}
|
||||
onUpdateCanDeploy={(canDeploy) => {
|
||||
canDeployToWorkspace = canDeploy
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canSeeTarget == undefined}
|
||||
{#if workspaceToDeployTo == undefined}
|
||||
<!-- A remote destination nobody has connected to yet: the connect form above is the next step. -->
|
||||
{:else if canSeeTarget == undefined}
|
||||
<div class="mt-6"></div>
|
||||
<Loader2 class="animate-spin" />
|
||||
{:else if canSeeTarget == 'yes'}
|
||||
@@ -604,6 +709,15 @@
|
||||
user to deploy this item using the shareable link or get the proper permissions on the
|
||||
dependencies</Alert
|
||||
>
|
||||
{:else if isRemote}
|
||||
<div class="my-2"></div>
|
||||
<Alert type="error" title="Could not deploy to {destinationLabel}"
|
||||
><p>{targetError ?? 'The remote instance did not accept the request'}</p>
|
||||
<p class="mt-1"
|
||||
>Disconnect above and connect again with a valid token for that instance, or ask someone
|
||||
permissioned there to deploy this item using the shareable link</p
|
||||
></Alert
|
||||
>
|
||||
{:else}
|
||||
<div class="my-2"></div>
|
||||
<Alert type="error" title="User not allowed to deploy to this workspace"
|
||||
|
||||
@@ -13,9 +13,13 @@
|
||||
|
||||
let {
|
||||
parentWorkspaceId,
|
||||
displayName,
|
||||
onUpdateCanDeploy = (value) => {}
|
||||
}: {
|
||||
parentWorkspaceId: string
|
||||
/** How to name the target in the message, when its id is not what a reader would recognize
|
||||
* — a remote deploy target is addressed through a proxy path. */
|
||||
displayName?: string
|
||||
onUpdateCanDeploy?: (value: boolean) => void
|
||||
} = $props()
|
||||
|
||||
@@ -73,7 +77,8 @@
|
||||
<Alert type="info" title="Target workspace protection active" class="my-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>
|
||||
The workspace {parentWorkspaceId} has a protection rule{activeDeployRulesets.length > 1
|
||||
The workspace {displayName ?? parentWorkspaceId} has a protection rule{activeDeployRulesets.length >
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
<b>{activeDeployRulesets.map((r) => r.name).join(', ')}</b>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { untrack } from 'svelte'
|
||||
import { WorkspaceService, type RemoteDeployConnection, type RemoteDeployTarget } from '$lib/gen'
|
||||
import {
|
||||
CHANNEL,
|
||||
remoteDeployAuthorizeUrl,
|
||||
remoteHost,
|
||||
type RemoteDeployEvent
|
||||
} from '$lib/remoteDeploy'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { ExternalLink, LogIn, Unplug } from 'lucide-svelte'
|
||||
import { Button } from './common'
|
||||
import Password from './Password.svelte'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
target,
|
||||
connection,
|
||||
returnTo,
|
||||
onChange
|
||||
}: {
|
||||
workspace: string
|
||||
target: RemoteDeployTarget
|
||||
connection: RemoteDeployConnection | undefined
|
||||
/** Where to land after signing in when the popup is blocked and this tab navigates away:
|
||||
* a page that shows the connection, since a drawer does not survive the round trip. */
|
||||
returnTo?: string
|
||||
onChange: () => void
|
||||
} = $props()
|
||||
|
||||
let host = $derived(remoteHost(target.base_url))
|
||||
let token: string | undefined = $state(undefined)
|
||||
let connecting = $state(false)
|
||||
let waitingForRemote = $state(false)
|
||||
let showPaste = $state(false)
|
||||
let error: string | undefined = $state(undefined)
|
||||
let popupWatch: ReturnType<typeof setInterval> | undefined = undefined
|
||||
|
||||
function signIn() {
|
||||
error = undefined
|
||||
const url = remoteDeployAuthorizeUrl(
|
||||
target,
|
||||
workspace,
|
||||
returnTo ?? page.url.pathname + page.url.search
|
||||
)
|
||||
// Opened blank and cut loose before it navigates: the remote page gets no handle on this
|
||||
// window, and a blocked popup is still detected.
|
||||
const popup = window.open('', 'windmill-remote-deploy', 'popup,width=640,height=760')
|
||||
if (!popup) {
|
||||
window.location.href = url
|
||||
return
|
||||
}
|
||||
popup.opener = null
|
||||
popup.location.href = url
|
||||
waitingForRemote = true
|
||||
clearInterval(popupWatch)
|
||||
popupWatch = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(popupWatch)
|
||||
waitingForRemote = false
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
$effect(() => () => clearInterval(popupWatch))
|
||||
|
||||
// The drawer stays mounted across a workspace switch: a token pasted for one target must not
|
||||
// be submitted to the next one.
|
||||
$effect(() => {
|
||||
;[workspace, target.base_url, target.workspace_id]
|
||||
untrack(() => {
|
||||
token = undefined
|
||||
error = undefined
|
||||
})
|
||||
})
|
||||
|
||||
// The callback page runs in the popup, or in this tab when the popup was blocked.
|
||||
$effect(() => {
|
||||
const channel = new BroadcastChannel(CHANNEL)
|
||||
channel.onmessage = (event: MessageEvent<RemoteDeployEvent>) => {
|
||||
if (event.data?.workspace !== workspace) return
|
||||
waitingForRemote = false
|
||||
if (event.data.type === 'connected') {
|
||||
onChange()
|
||||
} else {
|
||||
error = event.data.error
|
||||
}
|
||||
}
|
||||
return () => channel.close()
|
||||
})
|
||||
|
||||
async function connect() {
|
||||
if (!token) return
|
||||
connecting = true
|
||||
error = undefined
|
||||
try {
|
||||
await WorkspaceService.connectRemoteDeploy({ workspace, requestBody: { token, target } })
|
||||
token = undefined
|
||||
onChange()
|
||||
} catch (e: any) {
|
||||
error = e?.body ?? e?.message ?? String(e)
|
||||
} finally {
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await WorkspaceService.disconnectRemoteDeploy({ workspace })
|
||||
sendUserToast(`Disconnected from ${target.base_url}`)
|
||||
onChange()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if connection}
|
||||
<div class="flex flex-row items-center gap-2 text-xs text-secondary">
|
||||
<span>
|
||||
Deploying as <span class="font-semibold text-primary">{connection.remote_email}</span> on
|
||||
{target.base_url}
|
||||
</span>
|
||||
<Button variant="subtle" unifiedSize="xs" startIcon={{ icon: Unplug }} onclick={disconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 max-w-xl">
|
||||
<p class="text-xs text-secondary">
|
||||
Deploys to {target.workspace_id} on {target.base_url} run with your own account on that instance,
|
||||
so its permissions, protection rules and audit logs apply. Sign in there to connect; the token
|
||||
it issues is stored encrypted and only ever sent to that instance.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: LogIn }} onclick={signIn}>
|
||||
Sign in to {host}
|
||||
</Button>
|
||||
{#if waitingForRemote}
|
||||
<span class="text-xs text-secondary">Waiting for {host}…</span>
|
||||
{/if}
|
||||
<Button variant="subtle" unifiedSize="sm" onclick={() => (showPaste = !showPaste)}>
|
||||
{showPaste ? 'Hide token field' : 'Paste a token instead'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if showPaste}
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
class="text-xs flex flex-row items-center gap-1 w-fit"
|
||||
href="{target.base_url}/#user-settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Create a token on {host}
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
<div class="flex flex-row gap-2 items-start">
|
||||
<div class="grow">
|
||||
<Password
|
||||
bind:password={token}
|
||||
small
|
||||
allowMultiline={false}
|
||||
placeholder="Token for {target.base_url}"
|
||||
error={error != undefined}
|
||||
onKeyDown={(e) => e.key == 'Enter' && connect()}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={!token || connecting}
|
||||
loading={connecting}
|
||||
onclick={connect}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="text-xs text-red-500 dark:text-red-400 break-words">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { resource } from 'runed'
|
||||
import { Button } from './common'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import SettingCard from './instanceSettings/SettingCard.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
|
||||
let baseUrl = $state('')
|
||||
let remoteWorkspaceId = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
const targetResource = resource(
|
||||
() => $workspaceStore,
|
||||
async (workspace, previous, { signal }) => {
|
||||
if (workspace !== previous) {
|
||||
baseUrl = ''
|
||||
remoteWorkspaceId = ''
|
||||
}
|
||||
const status = workspace
|
||||
? await WorkspaceService.getRemoteDeployTarget({ workspace })
|
||||
: undefined
|
||||
// runed keeps a superseded fetch's result: filling the form with it would let a save
|
||||
// point the workspace switched to at the previous one's instance.
|
||||
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
|
||||
baseUrl = status?.target?.base_url ?? ''
|
||||
remoteWorkspaceId = status?.target?.workspace_id ?? ''
|
||||
return { workspace, target: status?.target }
|
||||
}
|
||||
)
|
||||
let loaded = $derived(
|
||||
targetResource.current?.workspace === $workspaceStore ? targetResource.current : undefined
|
||||
)
|
||||
let saved = $derived(loaded?.target)
|
||||
let hasChanges = $derived(
|
||||
baseUrl !== (saved?.base_url ?? '') || remoteWorkspaceId !== (saved?.workspace_id ?? '')
|
||||
)
|
||||
|
||||
async function save(target: { base_url: string; workspace_id: string } | undefined) {
|
||||
const workspace = loaded?.workspace
|
||||
if (!workspace) return
|
||||
saving = true
|
||||
try {
|
||||
const message = await WorkspaceService.setRemoteDeployTarget({
|
||||
workspace,
|
||||
requestBody: { target }
|
||||
})
|
||||
sendUserToast(message)
|
||||
await targetResource.refetch()
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? String(e), true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingCard
|
||||
label="Workspace on another instance"
|
||||
description="Deploy the same items to a workspace on a different Windmill instance. Each person deploying connects with their own token for that instance, from the deploy drawer."
|
||||
class="mt-6"
|
||||
>
|
||||
{#if !$enterpriseLicense}
|
||||
<Alert type="warning" title="Enterprise license required">
|
||||
Deploying to another instance from the web UI is only available with an enterprise license
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3 mt-2 max-w-md">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Instance URL</span>
|
||||
<TextInput
|
||||
bind:value={baseUrl}
|
||||
inputProps={{ placeholder: 'https://windmill.example.com' }}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace on that instance</span>
|
||||
<TextInput bind:value={remoteWorkspaceId} inputProps={{ placeholder: 'prod' }} />
|
||||
</label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!loaded || !hasChanges || !baseUrl || !remoteWorkspaceId || saving}
|
||||
onclick={() => save({ base_url: baseUrl, workspace_id: remoteWorkspaceId })}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{#if saved}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
destructive
|
||||
disabled={saving}
|
||||
onclick={() => save(undefined)}
|
||||
>
|
||||
Remove target
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if saved}
|
||||
<p class="text-xs text-secondary">
|
||||
Changing the instance URL or workspace drops every token stored for the current target, so
|
||||
everyone connects again.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</SettingCard>
|
||||
@@ -63,7 +63,8 @@
|
||||
!label.startsWith('embed_app:') &&
|
||||
!label.startsWith('sdk_app:') &&
|
||||
!label.startsWith('impersonation:') &&
|
||||
!label.startsWith('cli-login:')
|
||||
!label.startsWith('cli-login:') &&
|
||||
!label.startsWith('remote-deploy:')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { PENDING_TTL_MS, remoteDeployAuthorizeUrl, takePendingConnect } from './remoteDeploy'
|
||||
|
||||
const target = { base_url: 'https://prod.example.com', workspace_id: 'prod' }
|
||||
|
||||
function startConnect(workspace = 'dev'): string {
|
||||
const url = new URL(remoteDeployAuthorizeUrl(target, workspace, '/deploy/flow/f/a'))
|
||||
return url.searchParams.get('state')!
|
||||
}
|
||||
|
||||
describe('remote deploy connect state', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('window', { location: { origin: 'https://dev.example.com' } })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// The state is what keeps any other page from planting a token as the user's.
|
||||
it('matches only the state it issued, and only once', () => {
|
||||
const state = startConnect()
|
||||
expect(takePendingConnect('forged')).toBeUndefined()
|
||||
expect(takePendingConnect(state)).toMatchObject({ workspace: 'dev', target })
|
||||
expect(takePendingConnect(state)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps concurrent attempts apart', () => {
|
||||
const first = startConnect('dev')
|
||||
const second = startConnect('staging')
|
||||
expect(takePendingConnect(first)?.workspace).toBe('dev')
|
||||
expect(takePendingConnect(second)?.workspace).toBe('staging')
|
||||
})
|
||||
|
||||
it('expires an attempt left unfinished, and drops it from storage', () => {
|
||||
vi.useFakeTimers()
|
||||
const abandoned = startConnect()
|
||||
vi.advanceTimersByTime(PENDING_TTL_MS + 60_000)
|
||||
startConnect()
|
||||
expect(localStorage.length).toBe(1)
|
||||
expect(takePendingConnect(abandoned)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { base } from '$lib/base'
|
||||
import type { RemoteDeployConnection, RemoteDeployTarget } from '$lib/gen'
|
||||
import { randomSecret } from '$lib/utils/uuid'
|
||||
|
||||
/**
|
||||
* The workspace argument that aims a generated-client call at `workspace`'s deploy target on
|
||||
* another instance. The client fills `{workspace}` with `encodeURI`, which keeps the slashes, so
|
||||
* `/w/{workspace}/flows/create` reaches `/w/<workspace>/remote_deploy/proxy/<key>/flows/create`,
|
||||
* which the backend forwards to the remote workspace with the caller's token for it.
|
||||
*/
|
||||
export function remoteDeployWorkspace(
|
||||
workspace: string,
|
||||
connection: RemoteDeployConnection
|
||||
): string {
|
||||
return `${workspace}/remote_deploy/proxy/${connection.proxy_key}`
|
||||
}
|
||||
|
||||
export function remoteDeployLabel(target: RemoteDeployTarget): string {
|
||||
return `${target.workspace_id} on ${remoteHost(target.base_url)}`
|
||||
}
|
||||
|
||||
export function remoteHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting by signing in on the remote: the drawer opens the remote's authorize page, which mints
|
||||
// a token and sends it back in the fragment of `CALLBACK_PATH`; that page stores it through
|
||||
// `connect` and tells the drawer on `CHANNEL`.
|
||||
|
||||
/** The path the remote's authorize page only ever sends a token to. */
|
||||
export const CALLBACK_PATH = '/remote_deploy/callback'
|
||||
export const CHANNEL = 'windmill-remote-deploy'
|
||||
const PENDING_PREFIX = 'remote-deploy-connect:'
|
||||
export const PENDING_TTL_MS = 15 * 60_000
|
||||
|
||||
type PendingConnect = {
|
||||
workspace: string
|
||||
/** Sent back with the token, so it only reaches the instance it came from. */
|
||||
target: RemoteDeployTarget
|
||||
returnTo: string
|
||||
at: number
|
||||
}
|
||||
|
||||
export type RemoteDeployEvent =
|
||||
| { type: 'connected'; workspace: string }
|
||||
| { type: 'failed'; workspace: string; error: string }
|
||||
|
||||
/**
|
||||
* The remote authorize URL for connecting `workspace`. Records a `state` for the callback to
|
||||
* check: without it, any page could send this instance a token of its choosing to store as the
|
||||
* user's, and their deploys would land on the remote as someone else. `localStorage` rather than
|
||||
* `sessionStorage`, because the callback runs in the window the remote sends back to, which is
|
||||
* not always this one; one entry per attempt, so two tabs connecting at once do not cancel out.
|
||||
*/
|
||||
export function remoteDeployAuthorizeUrl(
|
||||
target: RemoteDeployTarget,
|
||||
workspace: string,
|
||||
returnTo: string
|
||||
): string {
|
||||
dropExpiredConnects()
|
||||
const state = randomSecret(16)
|
||||
const pending: PendingConnect = { workspace, target, returnTo, at: Date.now() }
|
||||
localStorage.setItem(PENDING_PREFIX + state, JSON.stringify(pending))
|
||||
const params = new URLSearchParams({
|
||||
workspace: target.workspace_id,
|
||||
callback: `${window.location.origin}${base}${CALLBACK_PATH}`,
|
||||
state
|
||||
})
|
||||
return `${target.base_url}/user/remote_deploy_authorize?${params}`
|
||||
}
|
||||
|
||||
/** An attempt abandoned before its callback would otherwise stay in storage for good. */
|
||||
function dropExpiredConnects() {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i)
|
||||
if (!key?.startsWith(PENDING_PREFIX)) continue
|
||||
let at = 0
|
||||
try {
|
||||
at = JSON.parse(localStorage.getItem(key) ?? '{}').at ?? 0
|
||||
} catch {}
|
||||
if (Date.now() - at > PENDING_TTL_MS) localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** The attempt `state` names, used once: a replayed callback finds nothing to match. */
|
||||
export function takePendingConnect(state: string): PendingConnect | undefined {
|
||||
const key = PENDING_PREFIX + state
|
||||
let pending: PendingConnect | undefined
|
||||
try {
|
||||
pending = JSON.parse(localStorage.getItem(key) ?? 'null') ?? undefined
|
||||
} catch {}
|
||||
localStorage.removeItem(key)
|
||||
if (!pending || Date.now() - pending.at > PENDING_TTL_MS) {
|
||||
return undefined
|
||||
}
|
||||
return pending
|
||||
}
|
||||
@@ -151,7 +151,10 @@ export async function getTriggersDeployData(
|
||||
kind: TriggerKind,
|
||||
path: string,
|
||||
workspace: string,
|
||||
onBehalfOf?: string
|
||||
onBehalfOf?: string,
|
||||
/** Root URL of the instance the trigger is deployed to, when that is not this one. A GCP or
|
||||
* Azure push subscription posts to the instance serving the trigger, so it has to name that one. */
|
||||
targetBaseUrl?: string
|
||||
) {
|
||||
const preservePermissionedAs = onBehalfOf !== undefined
|
||||
|
||||
@@ -247,7 +250,9 @@ export async function getTriggersDeployData(
|
||||
...gcpTrigger,
|
||||
delivery_config: gcpTrigger.delivery_config ?? undefined,
|
||||
base_endpoint:
|
||||
gcpTrigger.delivery_type === 'push' ? `${window.location.origin}${base}` : undefined,
|
||||
gcpTrigger.delivery_type === 'push'
|
||||
? (targetBaseUrl ?? `${window.location.origin}${base}`)
|
||||
: undefined,
|
||||
permissioned_as: onBehalfOf,
|
||||
preserve_permissioned_as: preservePermissionedAs
|
||||
}
|
||||
@@ -311,6 +316,11 @@ export async function getTriggersDeployData(
|
||||
return {
|
||||
data: {
|
||||
...azureTrigger,
|
||||
// Not stored on the trigger, but a push subscription is created from it.
|
||||
base_endpoint:
|
||||
azureTrigger.azure_mode === 'namespace_pull'
|
||||
? undefined
|
||||
: (targetBaseUrl ?? `${window.location.origin}${base}`),
|
||||
permissioned_as: onBehalfOf,
|
||||
preserve_permissioned_as: preservePermissionedAs
|
||||
},
|
||||
|
||||
@@ -362,6 +362,11 @@ export interface DeployItemParams {
|
||||
* between the two probes. The result then carries `conflict`.
|
||||
*/
|
||||
createOnly?: boolean
|
||||
/**
|
||||
* Root URL of the instance `workspaceTo` lives on, when the deploy crosses instances. Only what
|
||||
* a payload must state about where it landed needs it — a GCP or Azure push trigger's endpoint.
|
||||
*/
|
||||
targetBaseUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,7 +385,8 @@ export async function deployItem(
|
||||
additionalInformation,
|
||||
onBehalfOf,
|
||||
onBehalfOfPrincipal,
|
||||
createOnly
|
||||
createOnly,
|
||||
targetBaseUrl
|
||||
} = params
|
||||
|
||||
if (kind === 'trigger') {
|
||||
@@ -399,7 +405,8 @@ export async function deployItem(
|
||||
additionalInformation.triggers.kind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
onBehalfOf
|
||||
onBehalfOf,
|
||||
targetBaseUrl
|
||||
)
|
||||
if (alreadyExists) {
|
||||
// Strip operational state so the update doesn't flip the target's
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import { page } from '$app/state'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { CHANNEL, takePendingConnect, type RemoteDeployEvent } from '$lib/remoteDeploy'
|
||||
import { onMount } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
|
||||
// Where the remote's authorize page sends the token it minted, in the fragment so that no server
|
||||
// log or Referer ever holds it.
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1))
|
||||
|
||||
let status: 'connecting' | 'connected' | 'failed' = $state('connecting')
|
||||
let error: string | undefined = $state(undefined)
|
||||
let returnTo: string | undefined = $state(undefined)
|
||||
|
||||
function announce(event: RemoteDeployEvent) {
|
||||
const channel = new BroadcastChannel(CHANNEL)
|
||||
channel.postMessage(event)
|
||||
channel.close()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Nor the address bar and the history, once read.
|
||||
replaceState(page.url.pathname, page.state)
|
||||
const pending = takePendingConnect(fragment.get('state') ?? '')
|
||||
if (!pending) {
|
||||
status = 'failed'
|
||||
error =
|
||||
'This connection was not started from this browser, or took too long. Start again from the deploy drawer.'
|
||||
return
|
||||
}
|
||||
returnTo = pending.returnTo
|
||||
const token = fragment.get('token')
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error(fragment.get('error') ?? 'The remote instance sent no token')
|
||||
}
|
||||
await WorkspaceService.connectRemoteDeploy({
|
||||
workspace: pending.workspace,
|
||||
requestBody: { token, target: pending.target }
|
||||
})
|
||||
} catch (e: any) {
|
||||
const message: string = e?.body ?? e?.message ?? String(e)
|
||||
status = 'failed'
|
||||
error = message
|
||||
announce({ type: 'failed', workspace: pending.workspace, error: message })
|
||||
return
|
||||
}
|
||||
status = 'connected'
|
||||
announce({ type: 'connected', workspace: pending.workspace })
|
||||
// Only a window this instance opened can close itself; otherwise go back to where the
|
||||
// connection was started.
|
||||
window.close()
|
||||
await goto(pending.returnTo)
|
||||
})
|
||||
</script>
|
||||
|
||||
<CenteredModal title="Connecting to the remote instance">
|
||||
{#if status === 'connecting'}
|
||||
<div class="flex justify-center py-6"><Loader2 class="animate-spin" /></div>
|
||||
{:else if status === 'connected'}
|
||||
<p class="text-sm text-primary text-center">Connected. You can close this tab.</p>
|
||||
{:else}
|
||||
<Alert type="error" title="Could not connect">{error}</Alert>
|
||||
{#if returnTo}
|
||||
<div class="flex justify-center pt-4">
|
||||
<Button variant="default" unifiedSize="md" onclick={() => goto(returnTo!)}>Go back</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</CenteredModal>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { base } from '$lib/base'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { UserService } from '$lib/gen'
|
||||
import { CALLBACK_PATH } from '$lib/remoteDeploy'
|
||||
import { usersWorkspaceStore } from '$lib/stores'
|
||||
|
||||
// Another Windmill instance asks for a token to deploy into one workspace here as the signed-in
|
||||
// user. It gets the token only if they authorize, and only at `callback`, whose origin they are
|
||||
// shown: anyone can craft this link, so that origin is the whole decision.
|
||||
const workspace = page.url.searchParams.get('workspace') ?? ''
|
||||
const connectState = page.url.searchParams.get('state') ?? ''
|
||||
const callback = parseCallback(page.url.searchParams.get('callback'))
|
||||
|
||||
function parseCallback(raw: string | null): URL | undefined {
|
||||
try {
|
||||
const url = new URL(raw ?? '')
|
||||
const valid =
|
||||
(url.protocol === 'https:' || url.protocol === 'http:') &&
|
||||
url.pathname.endsWith(CALLBACK_PATH) &&
|
||||
!url.search &&
|
||||
!url.hash &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
return valid ? url : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// A framing page could lay its own content over the Authorize button and have it clicked.
|
||||
const framed = window.self !== window.top
|
||||
// Left to the user rather than refused: internal instances often serve plain http.
|
||||
const unencrypted =
|
||||
callback?.protocol === 'http:' &&
|
||||
!['localhost', '127.0.0.1', '[::1]'].includes(callback.hostname)
|
||||
// Bounds a grant that went to the wrong place; reconnecting is one click.
|
||||
const TOKEN_LIFETIME_MS = 90 * 24 * 3600 * 1000
|
||||
const invalid = !callback || !workspace || !/^[A-Za-z0-9]{8,128}$/.test(connectState)
|
||||
|
||||
let error: string | undefined = $state(undefined)
|
||||
let authorizing = $state(false)
|
||||
|
||||
function sendBack(params: Record<string, string>) {
|
||||
const fragment = new URLSearchParams({ ...params, state: connectState })
|
||||
window.location.replace(`${callback!.href}#${fragment}`)
|
||||
}
|
||||
|
||||
async function authorize() {
|
||||
authorizing = true
|
||||
error = undefined
|
||||
try {
|
||||
await UserService.whoami({ workspace })
|
||||
const token = await UserService.createToken({
|
||||
requestBody: {
|
||||
// Reserved in `is_user_token` (windmill-common), so its expiry sends no email: it is
|
||||
// renewed by connecting again from the deploying instance, not managed here.
|
||||
label: `remote-deploy:${callback!.host}`,
|
||||
workspace_id: workspace,
|
||||
expiration: new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString()
|
||||
}
|
||||
})
|
||||
sendBack({ token })
|
||||
} catch (e: any) {
|
||||
authorizing = false
|
||||
error =
|
||||
e?.status === 401 || e?.status === 404
|
||||
? `You are not a member of workspace ${workspace} on this instance`
|
||||
: (e?.body ?? e?.message ?? String(e))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<CenteredModal title="Authorize a deploy connection">
|
||||
{#if framed}
|
||||
<Alert type="error" title="Refused">
|
||||
This page cannot be used inside another page. Open it in its own tab.
|
||||
</Alert>
|
||||
{:else if invalid}
|
||||
<Alert type="error" title="Invalid request">
|
||||
This authorization link is malformed. Start again from the deploy drawer of the other
|
||||
instance.
|
||||
</Alert>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4 text-sm text-primary">
|
||||
<p>
|
||||
<span class="font-semibold">{callback!.origin}</span> asks to deploy into workspace
|
||||
<span class="font-semibold">{workspace}</span> on this instance as
|
||||
<span class="font-semibold">{$usersWorkspaceStore?.email ?? 'you'}</span>.
|
||||
</p>
|
||||
<p class="text-secondary">
|
||||
It will receive a token limited to that workspace, with your permissions in it, valid for up
|
||||
to 90 days. Only authorize if you just started this from {callback!.host}. You can revoke
|
||||
the token at any time in your account settings, under tokens.
|
||||
</p>
|
||||
{#if unencrypted}
|
||||
<Alert type="warning" title="Unencrypted connection">
|
||||
{callback!.origin} is served over plain http, so the token will cross the network unencrypted
|
||||
on its way there.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if error}
|
||||
<Alert type="error" title="Could not authorize">{error}</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row justify-around pt-6 gap-x-1">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="lg"
|
||||
onclick={() => sendBack({ error: 'Declined on the remote instance' })}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="lg"
|
||||
loading={authorizing}
|
||||
disabled={authorizing}
|
||||
onclick={authorize}
|
||||
>
|
||||
Authorize
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if framed || invalid}
|
||||
<div class="flex justify-center pt-4">
|
||||
<Button variant="default" unifiedSize="md" href={base}>Go to Windmill</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CenteredModal>
|
||||
@@ -8,6 +8,7 @@
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
|
||||
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
|
||||
import RemoteDeployTargetSetting from '$lib/components/RemoteDeployTargetSetting.svelte'
|
||||
import DevWorkspaceSetting from '$lib/components/DevWorkspaceSetting.svelte'
|
||||
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
@@ -1552,6 +1553,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The other destination the lineage cannot express: a workspace on another
|
||||
instance. Its own section, since it needs a credential per person. -->
|
||||
<div class="flex flex-col gap-2 max-w-2xl mt-8 pt-6 border-t">
|
||||
<span class="text-xs font-semibold text-emphasis">Deploy to another instance</span>
|
||||
<RemoteDeployTargetSetting />
|
||||
</div>
|
||||
{:else if tab == 'rulesets'}
|
||||
<SettingsPageHeader
|
||||
title="Workspace Protection Rulesets"
|
||||
|
||||
Reference in New Issue
Block a user