Files
Ruben Fiszel f9d5da11b7 feat: allow changing an account email in the superadmin settings (#10355)
* feat: allow changing an account email in the superadmin settings

* fix: cover slack_email and usage rows, and scope job rewrites to the queue

* fix: compare the destination email case-insensitively

* fix: only warn about the consequences once the email is edited

* docs: warn that changing an account email is a last resort

* fix: repoint app policies and raw-email permissioned_as, reject self-change

* fix: repoint folder default rules and guard the varchar(55) job column
2026-07-27 14:54:23 +02:00

663 lines
22 KiB
Rust

use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn user_url(port: u16, endpoint: &str, name: &str) -> String {
format!("http://localhost:{port}/api/w/test-workspace/users/{endpoint}/{name}")
}
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_user_endpoints(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/users");
// ===== Global (non-workspace) endpoints =====
let global_base = format!("http://localhost:{port}/api/users");
// --- global whoami ---
let resp = authed(client().get(format!("{global_base}/whoami")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["email"], "test@windmill.dev");
// --- get_email ---
let resp = authed(client().get(format!("{global_base}/email")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let email = resp.text().await?;
assert_eq!(email, "test@windmill.dev");
// --- exists_email ---
let resp = authed(client().get(format!("{global_base}/exists/test@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, true);
let resp = authed(client().get(format!("{global_base}/exists/nonexistent@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, false);
// --- list_as_super_admin ---
let resp = authed(client().get(format!("{global_base}/list_as_super_admin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let list = resp.json::<Vec<serde_json::Value>>().await?;
assert!(!list.is_empty());
// --- tokens/list ---
let resp = authed(client().get(format!("{global_base}/tokens/list")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// --- tokens/create ---
let resp = authed(client().post(format!("{global_base}/tokens/create")))
.json(&json!({"label": "ephemeral-test-token"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let new_token = resp.text().await?;
assert!(!new_token.is_empty());
let token_prefix = &new_token[..std::cmp::min(new_token.len(), 10)];
// --- tokens/update_scopes (set explicit scopes) ---
let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/{token_prefix}")))
.json(&json!({"scopes": ["jobs:run:scripts"]}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "update_scopes: {}", resp.text().await?);
// Verify via tokens/list that scopes were applied.
let resp = authed(client().get(format!("{global_base}/tokens/list")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let tokens = resp.json::<Vec<serde_json::Value>>().await?;
let updated = tokens
.iter()
.find(|t| t["token_prefix"] == *token_prefix)
.expect("token in list");
assert_eq!(updated["scopes"], json!(["jobs:run:scripts"]));
// --- tokens/update_scopes (clear scopes via null = full access) ---
let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/{token_prefix}")))
.json(&json!({"scopes": null}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- tokens/update_scopes on nonexistent prefix returns 404 ---
let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/zzznotreal")))
.json(&json!({"scopes": ["jobs:run:scripts"]}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
// --- tokens/delete ---
let resp = authed(client().delete(format!("{global_base}/tokens/delete/{token_prefix}")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete token: {}", resp.text().await?);
// --- list_invites ---
let resp = authed(client().get(format!("{global_base}/list_invites")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// --- username_info ---
let resp = authed(client().get(format!("{global_base}/username_info/test@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["username"], "test-user");
// --- global usage ---
let resp = authed(client().get(format!("{global_base}/usage")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- tutorial_progress (get, then set, then get again) ---
let resp = authed(client().get(format!("{global_base}/tutorial_progress")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<serde_json::Value>().await?;
let resp = authed(client().post(format!("{global_base}/tutorial_progress")))
.json(&json!({"progress": 42, "skipped_all": false}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"set tutorial_progress: {}",
resp.text().await?
);
let resp = authed(client().get(format!("{global_base}/tutorial_progress")))
.send()
.await
.unwrap();
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["progress"], 42);
// --- global update user ---
let resp = authed(client().post(format!("{global_base}/update/test2@windmill.dev")))
.json(&json!({"name": "Updated Test User 2"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"global update user: {}",
resp.text().await?
);
// --- setpassword (EE-gated in OSS) ---
let resp = authed(client().post(format!("{global_base}/setpassword")))
.json(&json!({"password": "new-test-password-123"}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"setpassword: unexpected status {}",
resp.status()
);
// --- all_runnables ---
let resp = authed(client().get(format!("{global_base}/all_runnables")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// --- onboarding (EE-gated in OSS) ---
let resp = authed(client().post(format!("{global_base}/onboarding")))
.json(&json!({"touch_point": "test", "use_case": "testing"}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"onboarding: unexpected status {}",
resp.status()
);
// --- decline_invite (no pending invite, but endpoint should handle gracefully) ---
let resp = authed(client().post(format!("{global_base}/decline_invite")))
.json(&json!({"workspace_id": "nonexistent-ws"}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 404,
"decline_invite: unexpected status {}",
resp.status()
);
// --- auth: is_first_time_setup (unauthed) ---
let resp = client()
.get(format!(
"http://localhost:{port}/api/auth/is_first_time_setup"
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let is_first = resp.json::<bool>().await?;
assert_eq!(is_first, false);
// --- auth: is_smtp_configured (unauthed) ---
let resp = client()
.get(format!(
"http://localhost:{port}/api/auth/is_smtp_configured"
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<bool>().await?;
// --- create user (global, EE-gated in OSS) ---
let resp = authed(client().post(format!("{global_base}/create")))
.json(&json!({
"email": "newglobaluser@windmill.dev",
"password": "test-password-123",
"super_admin": false,
"name": "New Global User"
}))
.send()
.await
.unwrap();
let create_status = resp.status();
assert!(
create_status == 201 || create_status == 500,
"create user: unexpected status {}",
create_status
);
if create_status == 201 {
// --- rename user (only if create succeeded / EE) ---
let resp =
authed(client().post(format!("{global_base}/rename/newglobaluser@windmill.dev")))
.json(&json!({"new_username": "renamed_user"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "rename user: {}", resp.text().await?);
// --- global delete user ---
let resp =
authed(client().delete(format!("{global_base}/delete/newglobaluser@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"global delete user: {}",
resp.text().await?
);
}
// ===== Auth (unauthed) endpoints =====
let auth_base = format!("http://localhost:{port}/api/auth");
// --- login (will fail: password hash in fixture is fake) ---
let resp = client()
.post(format!("{auth_base}/login"))
.json(&json!({"email": "test@windmill.dev", "password": "wrong-password"}))
.send()
.await
.unwrap();
assert!(
resp.status() == 400 || resp.status() == 401 || resp.status() == 500,
"login: unexpected status {}",
resp.status()
);
// --- logout (POST, with auth token) ---
let resp = authed(client().post(format!("{auth_base}/logout")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 303,
"logout POST: unexpected status {}",
resp.status()
);
// --- logout (GET, with auth token) ---
let resp = authed(client().get(format!("{auth_base}/logout")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 303,
"logout GET: unexpected status {}",
resp.status()
);
// --- request_password_reset (returns 400 if SMTP not configured) ---
let resp = client()
.post(format!("{auth_base}/request_password_reset"))
.json(&json!({"email": "test@windmill.dev"}))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 400,
"request_password_reset: unexpected status {}",
resp.status()
);
// --- reset_password (EE-gated, invalid token) ---
let resp = client()
.post(format!("{auth_base}/reset_password"))
.json(&json!({"token": "invalid-token", "new_password": "new-pass"}))
.send()
.await
.unwrap();
assert!(
resp.status() == 400 || resp.status() == 500,
"reset_password: unexpected status {}",
resp.status()
);
// ===== Workspace-scoped endpoints =====
// --- whoami ---
let resp = authed(client().get(format!("{base}/whoami")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["username"], "test-user");
assert_eq!(body["email"], "test@windmill.dev");
assert_eq!(body["is_admin"], true);
// --- list ---
let resp = authed(client().get(format!("{base}/list")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let list = resp.json::<Vec<serde_json::Value>>().await?;
assert!(!list.is_empty());
assert!(list.iter().any(|u| u["username"] == "test-user"));
// --- list_usernames ---
let resp = authed(client().get(format!("{base}/list_usernames")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let usernames = resp.json::<Vec<String>>().await?;
assert!(usernames.contains(&"test-user".to_string()));
// --- get ---
let resp = authed(client().get(user_url(port, "get", "test-user")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["username"], "test-user");
// --- whois ---
let resp = authed(client().get(user_url(port, "whois", "test-user")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["username"], "test-user");
// --- username_to_email ---
let resp = authed(client().get(user_url(port, "username_to_email", "test-user")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let email = resp.text().await?;
assert_eq!(email, "test@windmill.dev");
// --- exists ---
let resp = authed(client().post(format!("{base}/exists")))
.json(&json!({"username": "test-user"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, true);
let resp = authed(client().post(format!("{base}/exists")))
.json(&json!({"username": "nonexistent"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, false);
// --- list_usage ---
let resp = authed(client().get(format!("{base}/list_usage")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<Vec<serde_json::Value>>().await?;
// --- is_owner ---
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/users/is_owner/u/test-user/test"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- update (make non-admin, then revert) ---
let resp = authed(client().post(user_url(port, "update", "test-user")))
.json(&json!({"is_admin": false}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "update user: {}", resp.text().await?);
// revert back to admin
let resp = authed(client().post(user_url(port, "update", "test-user")))
.json(&json!({"is_admin": true}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- delete workspace user (admin deletes test-user-2) ---
let resp = authed(client().delete(user_url(port, "delete", "test-user-2")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete user: {}", resp.text().await?);
// verify deleted
let resp = authed(client().get(format!("{base}/list_usernames")))
.send()
.await
.unwrap();
let usernames = resp.json::<Vec<String>>().await?;
assert!(!usernames.contains(&"test-user-2".to_string()));
// --- leave workspace (test-user-3 leaves voluntarily) ---
let resp = client()
.post(format!("{base}/leave"))
.header("Authorization", "Bearer SECRET_TOKEN_3")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?);
// verify left
let resp = authed(client().get(format!("{base}/list_usernames")))
.send()
.await
.unwrap();
let usernames = resp.json::<Vec<String>>().await?;
assert!(!usernames.contains(&"test-user-3".to_string()));
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_change_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let global_base = format!("http://localhost:{port}/api/users");
let change_email = |email: &str, new_email: &str| {
authed(client().post(format!("{global_base}/change_email/{email}")))
.json(&json!({ "new_email": new_email }))
.send()
};
sqlx::query!("UPDATE password SET username = 'test-user-2' WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
sqlx::query!("UPDATE workspace SET owner = 'test2@windmill.dev' WHERE id = 'test-workspace'")
.execute(&db)
.await?;
// A user whose username is their email is stored as the bare address in `permissioned_as` and
// in an app's policy, rather than as `u/{username}`.
sqlx::query!(
"INSERT INTO schedule(workspace_id, path, edited_by, schedule, timezone, enabled, script_path, is_flow, args, email, permissioned_as)
VALUES ('test-workspace', 'u/test-user-2/sched', 'test-user-2', '0 0 1 1 *', 'UTC', false, 'u/test-user-2/s', false, '{}'::json, 'test2@windmill.dev', 'test2@windmill.dev')"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO app(workspace_id, path, summary, policy, versions)
VALUES ('test-workspace', 'u/test-user-2/app', '', '{\"on_behalf_of\": \"test2@windmill.dev\", \"on_behalf_of_email\": \"test2@windmill.dev\"}'::jsonb, '{}')"
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO folder(workspace_id, name, display_name, owners, extra_perms, default_permissioned_as)
VALUES ('test-workspace', 'fold', 'fold', '{}', '{}'::jsonb,
'[{\"path_glob\": \"a/**\", \"permissioned_as\": \"u/other\"}, {\"path_glob\": \"**\", \"permissioned_as\": \"test2@windmill.dev\"}]'::jsonb)"
)
.execute(&db)
.await?;
let resp = change_email("test2@windmill.dev", "renamed@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 200, "change_email: {}", resp.text().await?);
// The account row is moved, not recreated, so the instance-wide username and the workspace
// membership follow the new address.
let username =
sqlx::query_scalar!("SELECT username FROM password WHERE email = 'renamed@windmill.dev'")
.fetch_one(&db)
.await?;
assert_eq!(username.as_deref(), Some("test-user-2"));
let workspaces =
sqlx::query_scalar!("SELECT workspace_id FROM usr WHERE email = 'renamed@windmill.dev'")
.fetch_all(&db)
.await?;
assert_eq!(workspaces, vec!["test-workspace".to_string()]);
let owner = sqlx::query_scalar!("SELECT owner FROM workspace WHERE id = 'test-workspace'")
.fetch_one(&db)
.await?;
assert_eq!(owner, "renamed@windmill.dev");
// A `permissioned_as` (or app policy) holding the bare address is the sole identity reference
// those rows have: left stale, the schedule tick and the deployed app run as an account that no
// longer exists.
let permissioned_as = sqlx::query_scalar!(
"SELECT permissioned_as FROM schedule WHERE path = 'u/test-user-2/sched'"
)
.fetch_one(&db)
.await?;
assert_eq!(permissioned_as, "renamed@windmill.dev");
let policy = sqlx::query_scalar!(
"SELECT policy::text FROM app WHERE path = 'u/test-user-2/app' AND workspace_id = 'test-workspace'"
)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!policy.contains("test2@windmill.dev") && policy.contains("renamed@windmill.dev"),
"app policy should carry only the new address: {policy}"
);
// The rules keep their order, since the folder resolver takes the first glob that matches.
let rules = sqlx::query_scalar!(
"SELECT default_permissioned_as::text FROM folder WHERE workspace_id = 'test-workspace' AND name = 'fold'"
)
.fetch_one(&db)
.await?
.unwrap_or_default();
let rules: serde_json::Value = serde_json::from_str(&rules)?;
assert_eq!(rules[0]["permissioned_as"], "u/other");
assert_eq!(rules[1]["permissioned_as"], "renamed@windmill.dev");
let old_rows =
sqlx::query_scalar!("SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'")
.fetch_one(&db)
.await?;
assert_eq!(old_rows, Some(0));
// Moving onto an address that already has an account would merge two identities. Login
// lowercases what it is given, so a case-only difference collides just the same.
let resp = change_email("test3@windmill.dev", "renamed@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 400);
sqlx::query!(
"UPDATE password SET email = 'Legacy@windmill.dev' WHERE email = 'renamed@windmill.dev'"
)
.execute(&db)
.await?;
let resp = change_email("test3@windmill.dev", "legacy@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 400);
let resp = change_email("test3@windmill.dev", "not-an-email")
.await
.unwrap();
assert_eq!(resp.status(), 400);
let resp = change_email("nobody@windmill.dev", "somebody@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 404);
// Moving your own account would leave your cached identity pointing at a deleted address.
let resp = change_email("test@windmill.dev", "self@windmill.dev")
.await
.unwrap();
assert_eq!(resp.status(), 400);
// Only super admins may move an account.
let resp = client()
.post(format!("{global_base}/change_email/test3@windmill.dev"))
.header("Authorization", "Bearer SECRET_TOKEN_3")
.json(&json!({ "new_email": "hijacked@windmill.dev" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
Ok(())
}