mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
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:
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM tutorial_progress WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2f74858ebe82499e06283b0d22cc19ecf4e8c9a9ea6a76977bb17aefe9eb8f27"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user