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
This commit is contained in:
Ruben Fiszel
2026-07-27 14:54:23 +02:00
committed by GitHub
parent cdd8718a93
commit f9d5da11b7
64 changed files with 1746 additions and 4 deletions
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE postgres_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "03fdb7d0d4d95a98fdbdcf7dd1a9289119b9ce719d6a6c2975e96d7785819fc9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET slack_email = $1 WHERE slack_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "099067cf6c08642b205074771ef23d7ab950015d70f7065f5645f1e76d6a25ee"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE azure_trigger SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE schedule SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "0f49203698dce2d77724c3d2fbaf729c6b2c2f9dd9475b778dbce66f28b48e69"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder(workspace_id, name, display_name, owners, extra_perms, default_permissioned_as)\n VALUES ('test-workspace', 'fold', 'fold', '{}', '{}'::jsonb,\n '[{\"path_glob\": \"a/**\", \"permissioned_as\": \"u/other\"}, {\"path_glob\": \"**\", \"permissioned_as\": \"test2@windmill.dev\"}]'::jsonb)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "1159c2beb23057e3123847efa2b5d2d7cd7c89b47cbf5000fb74a9cdaa4b62df"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_fork_deployment_request_comment SET author_email = $1 WHERE author_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "162ab0cc9094d7031229a65f47d730282d17bc8a71a0e234c9edf81b85199dd1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1b828adc60798b096ebf5db75fc1ade1ffffff492dc02caafe002fd868d90a8d"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id FROM usr WHERE email = 'renamed@windmill.dev'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "24e8f955d2a9a3bf502736749563e82974a5744dbb4942c1f2b2dff774594252"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE mcp_oauth_server_code SET user_email = $1 WHERE user_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "273e18a8fe284da79b0929678f2e07d2db3240c1501d99e6b3d2fccfd7405d06"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "287a9f01a5b789b7724d7209f8f399638dd57f24fb407cbcb4fe4a510a74d3af"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_invite o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM workspace_invite n WHERE n.email = $2 AND n.workspace_id = o.workspace_id)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "299a5d12779185a51b6e6815208dfe0aae64c39637208d081462b84d8636aa42"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT owner FROM workspace WHERE id = 'test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "2b3ffacd09b0fa4bb458f18adbd7cb0f57455338d28e3f164a105a51510c1cc1"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM tutorial_progress WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE http_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "32e6b8ee457bcf28ed052f56ac8d24ad333ee5c75086ab1f07ed952cf6aecea9"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM usage WHERE id = $1 AND NOT is_workspace",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "34f1e04d3ff0ffe7fe4e142bb54e00431bc7fb4a4b20dacdce79316d7a84ba8f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job SET permissioned_as_email = $1 WHERE permissioned_as_email = $2 AND id IN (SELECT id FROM v2_job_queue)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "35384f86bf1058a228307655c545bfbe91032050603e882bca0d7352a0f08a62"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE kafka_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "35b7c715362239c09765d3717340216154b4bda0bd5729e93318fd6527fbbbc4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM password WHERE email = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "3b7e46f80cf1e543a76f90c74080a3e2d1cf04812aa1091e3c64f262988da099"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM password WHERE email = 'renamed@windmill.dev'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
true
]
},
"hash": "3c5e00d0e79fa518a95b05a00e75dbd96d7852ad340759252de4dcb57ad22a43"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_invite SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "3fec727489a5166c3036593d7089fd252e2c779762cb359f98a7d17e657a926f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE amqp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "44356ac660ef1113f21b3ec24d67de7e64cda8f1e8419c231ecc090447614c4e"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT permissioned_as FROM schedule WHERE path = 'u/test-user-2/sched'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "permissioned_as",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "5ae3532928770d6bd9fc57a3ca489f819d8845ddb54f09f53880a55cd2304c8c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE mqtt_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "5c3e4a9fd4ac5ce75326230ecbbd40bb126ae3e2dff1824904da91b2f7d900cd"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "5d1bcca26a78968351da909b23cb5fd0d90ed946263db6ffda99ca1cd9e727f9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE tutorial_progress SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "5e74efd5f9152deb9511d2f1efc084cbf6aa8f252d86a50fa3a4f4e93bf373aa"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app(workspace_id, path, summary, policy, versions)\n VALUES ('test-workspace', 'u/test-user-2/app', '', '{\"on_behalf_of\": \"test2@windmill.dev\", \"on_behalf_of_email\": \"test2@windmill.dev\"}'::jsonb, '{}')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "629a6f38411c49c13bfb7773cef87ef0b833f57e23392ba0f9c6d2a165a4c9fe"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job SET permissioned_as = $1 WHERE permissioned_as = $2 AND id IN (SELECT id FROM v2_job_queue)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "639f87dbe115bbd3d9eb9a70e74b4c8b3a71829aa9048fc2459e1f6603956e5d"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE websocket_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "647c12e69360e6a34d01a563e6e84b26192c18cb4ede596233c00187b789d9ec"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usage SET id = $1 WHERE id = $2 AND NOT is_workspace",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "65ee4ace29b39818594efdfa16453e4806c4dca776f80789a57d4d85e16aa197"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_fork_deployment_request_assignee SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "73910791d9eca97624c1b1b9dd65cc861d0339a4238bce6928af49601a6ad4d1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE email_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "774db0fcbc3905a8c80f271612efcaf7268011f8868028cad6d5339419cb7aef"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET username = 'test-user-2' WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "883be05a1a02dde8d9056094a4f17491791832d5bd206ab99a8b96ad30a0a3f5"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM email_to_igroup o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM email_to_igroup n WHERE n.email = $2 AND n.igroup = o.igroup)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8bd1c2f216b4c525888bb94f94e09c3c519eb4bccea78954cb25ff945807883c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE gcp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "9d6375303333eab6424dfba2b8fc4afb0d3fc87b79818b5087919a7ae604545f"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "a3233c8a26f4ca47e957d55e77d9e3ccd51dcf9d259527e0b1f20d6e7f43f80b"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO notify_event (channel, payload) SELECT 'notify_token_invalidation', token_prefix FROM token WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "a718367f00f7dc0280ab297ff66a939f930eb2a43beac528fd3b5fb6add5e251"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT policy::text FROM app WHERE path = 'u/test-user-2/app' AND workspace_id = 'test-workspace'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "policy",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "ab07fa69343f1ab5e2c89911c314d027a05302085c838aa1c2d84406f9fffdb2"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "acf96371f08812a15760436f43f05290ec39617e18e65f05fa4a9b002560a6d5"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_fork_deployment_request SET requested_by_email = $1 WHERE requested_by_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "aefab48bd35a3bbfdb6d5a86d017acdd2d3759e33b1c75e54aee140d8db562d6"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT default_permissioned_as::text FROM folder WHERE workspace_id = 'test-workspace' AND name = 'fold'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_permissioned_as",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "c22b71a3a40710e44e66aa48bda41de76b2cc0d0495c7d32f565d2548bf8c860"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM workspace WHERE owner = $1\n UNION ALL SELECT 1 FROM workspace_settings WHERE slack_email = $1\n UNION ALL SELECT 1 FROM usage WHERE id = $1 AND NOT is_workspace\n UNION ALL SELECT 1 FROM v2_job WHERE permissioned_as = $1 AND id IN (SELECT id FROM v2_job_queue))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c9513c08e03089a581a8bcc5178094fc42098ac26facbd010006ae06da284131"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE nats_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "c9d78891f8ccdfc12c8315653187cc8d2d4a2df257e5fd723016c6344174d4a1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE sqs_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d24faa96edde6ff6cb7c443775552419ff11b6671a58e3aa0c75a7b48973437a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM unique_ext_jwt_token WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d5f94950c15ee592d5580cf001b37560f2093f9569a2d8a4a702004041976961"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d7597022d6fc01601bff5f243fb38080fc6b65e95a3aad630c7abe3edcedbc98"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET owner = 'test2@windmill.dev' WHERE id = 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "d94140411f0ddefd74732e9a755249a044397b6fbfe164bc3dfd007ccd490388"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE azure_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d9b33bb1cf7894d5c61caa599975cc39b961191259bbccc233edca59bc7252b9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_perms SET email = $1 WHERE email = $2 AND job_id IN (SELECT id FROM v2_job_queue)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d9b877b970b8e7e5d895d9b4f83ba945776b17c153b77cb2933864e9cd313439"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE folder SET default_permissioned_as = (\n SELECT jsonb_agg(\n CASE WHEN rule->>'permissioned_as' = $2\n THEN jsonb_set(rule, ARRAY['permissioned_as'], to_jsonb($1::text))\n ELSE rule END\n ORDER BY ord)\n FROM jsonb_array_elements(default_permissioned_as) WITH ORDINALITY AS t(rule, ord))\n WHERE default_permissioned_as @> jsonb_build_array(jsonb_build_object('permissioned_as', $2::text))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "de902e3eded6c244ea4b4594c30e32b9e86b1902bfa504d60f0b3bef8f1e8ea6"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET email = 'Legacy@windmill.dev' WHERE email = 'renamed@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "df893ad32fd18b55d975247289d32fb80cbd8668c7c50c1f0b2ebd5faf50dce1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET owner = $1 WHERE owner = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e084010d1ff7492b77156c01bdaebae14f8c39ba0715fc1000fb8ff1bf78169f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM password WHERE lower(email) = $1 AND email <> $2\n UNION ALL SELECT 1 FROM usr WHERE lower(email) = $1 AND email <> $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ececc8132f985b3e2d8301d5dc24356953e6e6cd71f28384b6f51231a35c7947"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE capture_config SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eeb0ef2be5db28fa722a977dfbecfe0dddc1d33b3f98c649cd91d9a3a5785e34"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE mcp_oauth_refresh_token SET user_email = $1 WHERE user_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eff07725675fa97c91f86564c13658316f5c3dff23704feca654dedc035adacf"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of_email'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of_email' = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f8c3c8cc0b3bb90adf611bab39f71cdd9a96e35508c852ab9c1dcbae3decbaba"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO schedule(workspace_id, path, edited_by, schedule, timezone, enabled, script_path, is_flow, args, email, permissioned_as)\n 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')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "f8fa9ba552697292752e80a224e9c85bbed7cb0196c40927bf5a542416cf3b15"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "fbd24c0bc6c364d643f81f0a1baa4db6beaa12b133932ce0fd6c16a771a5a594"
}
+10 -4
View File
@@ -38,15 +38,21 @@ lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
// Cache for token -> email lookups (for non-workspace-member authenticated users)
static ref TOKEN_EMAIL_CACHE: Cache<String, Option<String>> = Cache::new(500);
static ref TOKEN_EMAIL_CACHE: Cache<String, (Option<String>, std::time::Instant)> = Cache::new(500);
}
/// A token keeps its identity when a superadmin moves the account to another address, so entries
/// here must expire on their own; nothing invalidates them by token hash.
const TOKEN_EMAIL_CACHE_TTL_SECS: u64 = 60;
/// Get email from a valid token, with caching.
/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member.
async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
let t_hash = hash_token(token);
if let Some(cached) = TOKEN_EMAIL_CACHE.get(&t_hash) {
return cached;
if let Some((cached, cached_at)) = TOKEN_EMAIL_CACHE.get(&t_hash) {
if cached_at.elapsed().as_secs() < TOKEN_EMAIL_CACHE_TTL_SECS {
return cached;
}
}
let email = sqlx::query_scalar!(
@@ -59,7 +65,7 @@ async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
.flatten()
.flatten(); // email column is nullable, so we get Option<Option<String>>
TOKEN_EMAIL_CACHE.insert(t_hash, email.clone());
TOKEN_EMAIL_CACHE.insert(t_hash, (email.clone(), std::time::Instant::now()));
email
}
@@ -511,3 +511,152 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
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(())
}
+527
View File
@@ -48,6 +48,9 @@ use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::users::truncate_token;
use windmill_common::users::COOKIE_NAME;
use windmill_common::users::{
SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL, VALID_EMAIL,
};
use windmill_common::utils::paginate;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::{
@@ -133,6 +136,7 @@ pub fn global_service() -> Router {
.route("/update/{user}", post(update_user))
.route("/delete/{user}", delete(delete_user))
.route("/username_info/{user}", get(get_instance_username_info))
.route("/change_email/{user}", post(change_user_email))
.route("/tokens/create", post(create_token))
.route("/tokens/delete/{token_prefix}", delete(delete_token))
.route(
@@ -1664,6 +1668,529 @@ async fn delete_user(
Ok(format!("email {} deleted", &email_to_delete))
}
#[derive(Deserialize)]
struct ChangeUserEmail {
new_email: String,
}
/// `workspace.owner`, `workspace_settings.slack_email` and `usage.id` hold an email in a
/// `varchar(50)`, and `v2_job.permissioned_as` in a `varchar(55)`; every other email column is
/// `varchar(255)`. The strictest of the two bounds is used for all of them.
const SHORT_EMAIL_COLUMN_MAX_LEN: usize = 50;
const EMAIL_COLUMN_MAX_LEN: usize = 255;
/// Move an account to a new email address, in place: the `password` row (and with it the
/// instance-wide username, the role and the login type) is kept and every email-keyed row is
/// repointed at the new address.
///
/// `audit` is deliberately left alone: it records who did what at the time, so rewriting it
/// would falsify history.
async fn change_user_email(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Path(old_email): Path<String>,
Extension(db): Extension<DB>,
Json(ce): Json<ChangeUserEmail>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
forbid_superadmin_job_token(&db, &authed.email, job_id).await?;
// The target is matched verbatim (accounts predating email normalization can hold uppercase),
// while the new address is normalized the same way account creation and login do.
let old_email = old_email.trim().to_string();
let new_email = ce.new_email.trim().to_lowercase();
if !VALID_EMAIL.is_match(&new_email) || new_email.len() > EMAIL_COLUMN_MAX_LEN {
return Err(Error::BadRequest(format!(
"{new_email} is not a valid email address of at most {EMAIL_COLUMN_MAX_LEN} characters"
)));
}
if new_email == old_email {
return Err(Error::BadRequest(
"The new email is identical to the current one".to_string(),
));
}
// Every API server caches the caller's identity behind their token and only drops it when the
// invalidation event is polled, so moving your own account would leave you authenticating as an
// address that no longer exists for a few seconds.
if old_email.eq_ignore_ascii_case(&authed.email) {
return Err(Error::BadRequest(
"You cannot change your own email, ask another superadmin to do it".to_string(),
));
}
for reserved in [
SUPERADMIN_SECRET_EMAIL,
SUPERADMIN_NOTIFICATION_EMAIL,
SUPERADMIN_SYNC_EMAIL,
] {
if old_email == reserved || new_email == reserved {
return Err(Error::BadRequest(format!(
"{reserved} is a reserved email address"
)));
}
}
let mut tx = db.begin().await?;
// FOR UPDATE serializes concurrent moves of *this* account. Two moves of different accounts
// onto the same destination are stopped by the `password` primary key instead, which is why the
// unique violation below is mapped back onto the same 400 as the conflict check.
let username = sqlx::query_scalar!(
"SELECT username FROM password WHERE email = $1 FOR UPDATE",
&old_email
)
.fetch_optional(&mut *tx)
.await?;
let username = not_found_if_none(username, "user", &old_email)?;
// Compared case-insensitively: login lowercases what it is given, so an account stored with
// uppercase would be shadowed by a lowercase twin rather than collide with it. The moved
// account is excluded so that normalizing its own address to lowercase stays allowed.
let taken = sqlx::query_scalar!(
"SELECT EXISTS(
SELECT 1 FROM password WHERE lower(email) = $1 AND email <> $2
UNION ALL SELECT 1 FROM usr WHERE lower(email) = $1 AND email <> $2)",
&new_email,
&old_email
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if taken {
return Err(Error::BadRequest(format!(
"{new_email} is already used by another account"
)));
}
if new_email.len() > SHORT_EMAIL_COLUMN_MAX_LEN {
let referenced_by_short_column = sqlx::query_scalar!(
"SELECT EXISTS(
SELECT 1 FROM workspace WHERE owner = $1
UNION ALL SELECT 1 FROM workspace_settings WHERE slack_email = $1
UNION ALL SELECT 1 FROM usage WHERE id = $1 AND NOT is_workspace
UNION ALL SELECT 1 FROM v2_job WHERE permissioned_as = $1 AND id IN (SELECT id FROM v2_job_queue))",
&old_email
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if referenced_by_short_column {
return Err(Error::BadRequest(format!(
"{new_email} is longer than {SHORT_EMAIL_COLUMN_MAX_LEN} characters and this user owns a workspace, a Slack connection, usage counters or a queued job, whose columns cannot hold it"
)));
}
}
// A pending_user row only reserves a username for an address that has no account yet, which
// stops being true here. The moved account keeps its own username.
sqlx::query!("DELETE FROM pending_user WHERE email = $1", &new_email)
.execute(&mut *tx)
.await?;
// ---- account ---- (draft.email follows through its ON UPDATE CASCADE fkey)
sqlx::query!(
"UPDATE password SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
Error::BadRequest(format!("{new_email} is already used by another account"))
}
_ => e.into(),
})?;
sqlx::query!(
"UPDATE usr SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace SET owner = $1 WHERE owner = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Slack commands run as this address when the command maps to no workspace user.
sqlx::query!(
"UPDATE workspace_settings SET slack_email = $1 WHERE slack_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Per-user monthly execution counters, keyed by the email.
sqlx::query!(
"DELETE FROM usage WHERE id = $1 AND NOT is_workspace",
&new_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE usage SET id = $1 WHERE id = $2 AND NOT is_workspace",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- instance groups and invites ---- (both keyed on the email, so drop the rows that would
// collide with what the new address was already granted before merging the old ones in)
sqlx::query!(
"DELETE FROM email_to_igroup o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM email_to_igroup n WHERE n.email = $2 AND n.igroup = o.igroup)",
&old_email,
&new_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE email_to_igroup SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM workspace_invite o WHERE o.email = $1 AND EXISTS (SELECT 1 FROM workspace_invite n WHERE n.email = $2 AND n.workspace_id = o.workspace_id)",
&old_email,
&new_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_invite SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM tutorial_progress WHERE email = $1", &new_email)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE tutorial_progress SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- credentials ---- (a password reset link was mailed to the old address)
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &old_email)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE token SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Tokens stay valid, but every API server caches the authed user behind the raw token, so ask
// them all to drop those entries rather than serve the previous address until they expire.
sqlx::query!(
"INSERT INTO notify_event (channel, payload) SELECT 'notify_token_invalidation', token_prefix FROM token WHERE email = $1",
&new_email
)
.execute(&mut *tx)
.await?;
// An external JWT still asserts the old address, so its cached mapping is stale.
sqlx::query!(
"DELETE FROM unique_ext_jwt_token WHERE email = $1",
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE mcp_oauth_refresh_token SET user_email = $1 WHERE user_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE mcp_oauth_server_code SET user_email = $1 WHERE user_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- runnables run on behalf of the user ----
sqlx::query!(
"UPDATE schedule SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE capture_config SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE azure_trigger SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// Apps carry the same identity inside their policy JSONB. An app running in Anonymous or
// Publisher mode takes its permissions from there rather than from the caller, so a stale
// address silently costs it its superadmin flag and its instance groups.
sqlx::query!(
"UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of_email'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of_email' = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- permissioned_as holding a raw email ----
// `username_to_permissioned_as` returns its input verbatim when it contains '@', and the
// migration that introduced these columns back-filled them the same way, so a user whose
// username is their email is stored as the bare address instead of `u/{username}`. Those rows
// are the ones that go stale here; `u/{username}` rows are safe because the username is kept.
sqlx::query!(
"UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// A folder's default rules are an ordered array, first match wins, so the rewrite has to
// preserve their order. A rule left on the old address makes `ensure_permissioned_as_exists`
// reject the creation of every runnable the rule matches.
sqlx::query!(
r#"UPDATE folder SET default_permissioned_as = (
SELECT jsonb_agg(
CASE WHEN rule->>'permissioned_as' = $2
THEN jsonb_set(rule, ARRAY['permissioned_as'], to_jsonb($1::text))
ELSE rule END
ORDER BY ord)
FROM jsonb_array_elements(default_permissioned_as) WITH ORDINALITY AS t(rule, ord))
WHERE default_permissioned_as @> jsonb_build_array(jsonb_build_object('permissioned_as', $2::text))"#,
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE http_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE websocket_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE postgres_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE mqtt_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE kafka_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE nats_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE sqs_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE gcp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE email_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE amqp_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE azure_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- jobs ---- Restricted to what is still queued: those rows drive the permissions of a run
// that has not finished yet, whereas completed jobs are history and neither column is indexed
// (rewriting every past row of a busy user would hold this transaction's locks for minutes).
sqlx::query!(
"UPDATE v2_job SET permissioned_as_email = $1 WHERE permissioned_as_email = $2 AND id IN (SELECT id FROM v2_job_queue)",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE v2_job SET permissioned_as = $1 WHERE permissioned_as = $2 AND id IN (SELECT id FROM v2_job_queue)",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE job_perms SET email = $1 WHERE email = $2 AND job_id IN (SELECT id FROM v2_job_queue)",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// ---- fork deployment requests ----
sqlx::query!(
"UPDATE workspace_fork_deployment_request SET requested_by_email = $1 WHERE requested_by_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_fork_deployment_request_assignee SET email = $1 WHERE email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE workspace_fork_deployment_request_comment SET author_email = $1 WHERE author_email = $2",
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.change_email",
ActionKind::Update,
"global",
Some(&old_email),
Some([("new_email", new_email.as_str())].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"changed email of user {old_email} to {new_email}{}",
username
.map(|u| format!(", keeping the instance username {u}"))
.unwrap_or_default()
))
}
lazy_static::lazy_static! {
pub static ref NEW_USER_WEBHOOK: Option<String> = std::env::var("NEW_USER_WEBHOOK").ok();
+32
View File
@@ -732,6 +732,38 @@ paths:
schema:
type: string
/users/change_email/{email}:
post:
summary: global change user email (require super admin)
operationId: globalUserChangeEmail
tags:
- user
parameters:
- name: email
in: path
required: true
schema:
type: string
requestBody:
description: new email
required: true
content:
application/json:
schema:
type: object
properties:
new_email:
type: string
required:
- new_email
responses:
"200":
description: user email changed
content:
text/plain:
schema:
type: string
/users/delete/{email}:
delete:
summary: global delete user (require super admin)
+30
View File
@@ -6,6 +6,15 @@
* LICENSE-AGPL for a copy of the license.
*/
lazy_static::lazy_static! {
/// Matches the non-quoted, non-IP-literal subset of the `proper_email` CHECK constraint
/// carried by `usr` and `workspace_invite`, so anything accepted here is accepted by those
/// tables too.
pub static ref VALID_EMAIL: regex::Regex = regex::Regex::new(
r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$"
).unwrap();
}
pub const SUPERADMIN_SECRET_EMAIL: &str = "superadmin_secret@windmill.dev";
pub const SUPERADMIN_NOTIFICATION_EMAIL: &str = "superadmin_notification@windmill.dev";
pub const SUPERADMIN_SYNC_EMAIL: &str = "superadmin_sync@windmill.dev";
@@ -167,6 +176,27 @@ mod tests {
assert_eq!(username_to_permissioned_as("group-my-team"), "g/my-team");
}
#[test]
fn test_valid_email() {
for email in [
"alice@example.com",
"alice.bob+tag@sub.example.co.uk",
"a_b-c!#$%&'*+/=?^_`{|}~@example.com",
] {
assert!(VALID_EMAIL.is_match(email), "{email} should be valid");
}
for email in [
"alice",
"alice@example",
"alice@@example.com",
"alice @example.com",
"alice@example.com\nbob@example.com",
"",
] {
assert!(!VALID_EMAIL.is_match(email), "{email} should be invalid");
}
}
#[test]
fn test_compute_highest_workspace_role_admin_wins() {
let user_groups = vec!["ops".to_string(), "admins".to_string()];
@@ -0,0 +1,101 @@
<script lang="ts">
import { UserService } from '$lib/gen'
import { Button } from './common'
import { sendUserToast } from '$lib/toast'
import Alert from './common/alert/Alert.svelte'
import TextInput from './text_input/TextInput.svelte'
import { createEventDispatcher } from 'svelte'
import { userStore } from '$lib/stores'
interface Props {
email: string
username?: string | undefined
noPadding?: boolean
}
let { email, username = undefined, noPadding = false }: Props = $props()
let editedEmail: string | undefined = $state(undefined)
// Lowercased like the backend normalizes it, so a capitalization-only edit is not offered as a
// change the backend would then reject.
let newEmail = $derived((editedEmail ?? email).trim().toLowerCase())
let isSelf = $derived(email === $userStore?.email)
let changed = $derived(!!newEmail && newEmail !== email.toLowerCase())
let loading = $state(false)
const dispatch = createEventDispatcher()
async function changeEmail() {
if (!changed || loading) {
return
}
loading = true
try {
await UserService.globalUserChangeEmail({ email, requestBody: { new_email: newEmail } })
sendUserToast(`Changed email of ${email} to ${newEmail}`)
dispatch('changed')
} catch (err) {
sendUserToast(err.body ?? err.message ?? 'Failed to change email', true)
} finally {
loading = false
}
}
</script>
<div class="flex flex-col max-w-2xl {noPadding ? '' : 'p-4'}">
<span class="text-xs font-semibold text-emphasis mb-1 leading-6">Email</span>
<TextInput
inputProps={{
type: 'email',
disabled: isSelf,
onclick: (e) => {
e.stopPropagation()
},
onkeydown: (e) => {
e.stopPropagation()
},
onkeypress: (e) => {
e.stopPropagation()
if (e.key === 'Enter') {
changeEmail()
}
}
}}
bind:value={() => editedEmail ?? email, (v) => (editedEmail = String(v))}
/>
{#if isSelf}
<div class="text-2xs text-tertiary mt-1">
You cannot change your own email — ask another superadmin.
</div>
{/if}
{#if changed && !isSelf}
<Alert type="warning" title="Last resort operation" class="mt-2 mb-2" size="xs">
Changing the email of an existing account is a last resort. Prefer it only when the address
itself has to change and the account must be kept.
<br />
<br />
The account keeps its instance-wide username{username ? ` (${username})` : ''}, role,
workspace memberships, drafts and tokens. But past runs and audit logs keep the previous
email, pending password reset links stop working, and the account inherits any instance group
membership or workspace invite already addressed to the new email — including one that grants
a role.
<br />
<br />
If this user signs in through SSO or is managed by SCIM, update the identity provider first: otherwise
their next login recreates the previous email as a separate, empty account.
</Alert>
{/if}
<Button
variant="default"
unifiedSize="md"
btnClasses="mt-2"
disabled={!changed || isSelf}
{loading}
on:click={() => changeEmail()}
>
Update email
</Button>
</div>
@@ -8,6 +8,7 @@
import Popover from './meltComponents/Popover.svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
import ChangeInstanceEmailInner from './ChangeInstanceEmailInner.svelte'
import { UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import TextInput from './text_input/TextInput.svelte'
@@ -66,6 +67,12 @@
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-8 max-w-sm p-4">
<ChangeInstanceEmailInner
{email}
{username}
noPadding
on:changed={() => dispatch('refresh')}
/>
{#if automateUsernameCreation && username}
<ChangeInstanceUsernameInner {email} {username} on:renamed noPadding />
{/if}