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:
Ruben Fiszel
2026-09-22 01:40:53 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 2699d5065e
commit 4d12ea4614
55 changed files with 2678 additions and 78 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+3
View File
@@ -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;
+4 -1
View File
@@ -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(())
}
+23 -2
View File
@@ -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",
+132
View File
@@ -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:
+6
View File
@@ -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(
+3 -2
View File
@@ -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()
};
+12 -1
View File
@@ -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
View File
@@ -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&nbsp; <Tooltip
>Workspace to deploy to is set in the workspace settings</Tooltip
>Destination&nbsp; <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:')
)
}
+44
View File
@@ -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()
})
})
+101
View File
@@ -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
}
+12 -2
View File
@@ -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
},
+9 -2
View File
@@ -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"