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) -> 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::().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::().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::().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::().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::().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::().await?["proxy_key"] .as_str() .unwrap() .to_string(); let resp = as_test2(client().get(format!("{base}/target"))) .send() .await?; assert_eq!( resp.json::().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::().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::().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::().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::().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(()) }