From e5286f46074cf2893e6ccd26175f929f16011c8f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 14 May 2026 06:46:48 +0000 Subject: [PATCH] feat: include service accounts in instance settings users list (#9157) * feat: include service accounts in instance settings users list Service accounts (workspace-scoped, no password row) now appear in the superadmin users list with a Bot icon, workspace badge, and a link to manage them in the workspace settings. Role is locked to Operator. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update sqlx offline cache Co-Authored-By: Claude Opus 4.7 (1M context) * fix: use composite key for users each block Service accounts can share emails across workspaces, so key by email + workspace_id to avoid Svelte each_key_duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...6794f18d33e5bd6340c0189be7818cda64328.json | 94 +++++ ...e5fe144c59156a8f06d8592291684c49b2f37.json | 95 +++++ ...09d02cfabe7e0864af577df6968428ac448ef.json | 95 +++++ backend/windmill-api-users/src/users.rs | 27 +- backend/windmill-api/openapi.yaml | 6 +- .../components/SuperadminSettingsInner.svelte | 397 ++++++++++-------- 6 files changed, 530 insertions(+), 184 deletions(-) create mode 100644 backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json create mode 100644 backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json create mode 100644 backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json diff --git a/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json b/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json new file mode 100644 index 0000000000..461d1afb14 --- /dev/null +++ b/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "devops", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "first_time_user", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + false, + false, + false, + true, + true, + true, + null, + false, + false, + false, + null + ] + }, + "hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328" +} diff --git a/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json b/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json new file mode 100644 index 0000000000..023eaaa010 --- /dev/null +++ b/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37" +} diff --git a/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json b/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json new file mode 100644 index 0000000000..36be033396 --- /dev/null +++ b/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef" +} diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 2ae6c838c1..0eb9d26b5d 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -210,6 +210,8 @@ pub struct GlobalUserInfo { first_time_user: bool, role_source: String, disabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + workspace_id: Option, } #[derive(Serialize, Debug)] @@ -451,13 +453,17 @@ async fn list_users_as_super_admin( let rows = if active_only.is_some_and(|x| x) { sqlx::query_as!( GlobalUserInfo, - "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), + r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) - SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled + SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password WHERE email IN (SELECT email FROM active_users) - ORDER BY super_admin DESC, devops DESC - LIMIT $1 OFFSET $2", + UNION ALL + SELECT email as "email!", true as operator_only, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + FROM usr + WHERE is_service_account IS true + ORDER BY "super_admin!" DESC, "devops!" DESC + LIMIT $1 OFFSET $2"#, per_page as i32, offset as i32 ) @@ -466,8 +472,13 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ - $1 OFFSET $2", + r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password + UNION ALL + SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + FROM usr + WHERE is_service_account IS true + ORDER BY "super_admin!" DESC, "devops!" DESC, "email!" + LIMIT $1 OFFSET $2"#, per_page as i32, offset as i32 ) @@ -716,7 +727,7 @@ async fn global_whoami( ) -> JsonResult { let user = sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \ email = $1", email ) @@ -740,6 +751,7 @@ async fn global_whoami( first_time_user: false, role_source: "manual".to_string(), disabled: false, + workspace_id: None, })) } else { // Service accounts don't have a password row @@ -756,6 +768,7 @@ async fn global_whoami( first_time_user: false, role_source: "service_account".to_string(), disabled: false, + workspace_id: None, })) } } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f73db0f362..da42ceb7e4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -26363,7 +26363,7 @@ components: type: string login_type: type: string - enum: ["password", "github"] + enum: ["password", "github", "service_account"] super_admin: type: boolean devops: @@ -26382,9 +26382,11 @@ components: type: boolean role_source: type: string - enum: ["manual", "instance_group"] + enum: ["manual", "instance_group", "service_account"] disabled: type: boolean + workspace_id: + type: string required: - email diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index c652293fc0..a9824de414 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -21,12 +21,15 @@ import { ArrowRightLeft, Ban, + Bot, CheckCircle2, ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' + import Badge from './common/badge/Badge.svelte' + import Tooltip from './Tooltip.svelte' import DropdownV2 from './DropdownV2.svelte' import Popover from './meltComponents/Popover.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' @@ -219,7 +222,16 @@ {filter} items={users} bind:filteredItems={filteredUsers} - f={(x) => x.email + ' ' + x.name + ' ' + x.company} + f={(x) => + (x.email ?? '') + + ' ' + + (x.name ?? '') + + ' ' + + (x.company ?? '') + + ' ' + + (x.username ?? '') + + ' ' + + (x.workspace_id ?? '')} />
@@ -393,7 +405,8 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))} + {@const isServiceAccount = login_type === 'service_account'}
- {email} + {#if isServiceAccount} + + {email} + {:else} + {email} + {/if} + {#if workspace_id} + + {truncate(workspace_id, 20)} + + {/if} {#if disabled} {/if} -
- {#key `${super_admin}_${devops}_${role_source}`} - { - if (email == $userStore?.email) { - sendUserToast('You cannot demote yourself', true) + {#if isServiceAccount} +
+ + Operator + + Service accounts are always operators. +
+ {:else} +
+ {#key `${super_admin}_${devops}_${role_source}`} + { + if (email == $userStore?.email) { + sendUserToast('You cannot demote yourself', true) + listUsers(activeOnly) + return + } + + let role = e.detail + + if (role === 'super_admin') { + await UserService.globalUserUpdate({ + email, + requestBody: { + is_super_admin: true, + is_devops: false + } + }) + } + if (role === 'devops') { + await UserService.globalUserUpdate({ + email, + requestBody: { + is_super_admin: false, + is_devops: true + } + }) + } + if (role === 'user') { + await UserService.globalUserUpdate({ + email, + requestBody: { + is_super_admin: false, + is_devops: false + } + }) + } + sendUserToast('User updated') listUsers(activeOnly) - return - } - - let role = e.detail - - if (role === 'super_admin') { - await UserService.globalUserUpdate({ - email, - requestBody: { - is_super_admin: true, - is_devops: false - } - }) - } - if (role === 'devops') { - await UserService.globalUserUpdate({ - email, - requestBody: { - is_super_admin: false, - is_devops: true - } - }) - } - if (role === 'user') { - await UserService.globalUserUpdate({ - email, - requestBody: { - is_super_admin: false, - is_devops: false - } - }) - } - sendUserToast('User updated') - listUsers(activeOnly) - }} - > - {#snippet children({ item })} - - - - {/snippet} - - {/key} - {#if role_source === 'instance_group' && (super_admin || devops)} - closeDrawer?.()} - > - Set by instance group - - {/if} -
+ }} + > + {#snippet children({ item })} + + + + {/snippet} +
+ {/key} + {#if role_source === 'instance_group' && (super_admin || devops)} + closeDrawer?.()} + > + Set by instance group + + {/if} +
+ {/if}
- - { - const btn = editWrappers[email]?.querySelector( - '[aria-label="Popup button"]' - ) - if (btn instanceof HTMLElement) btn.click() - } - }, - { - displayName: disabled ? 'Enable' : 'Disable', - icon: disabled ? CheckCircle2 : Ban, - action: () => { - if (!disabled) { - disableUserEmail = email - disableConfirmedCallback = async () => { - try { - await UserService.globalUserUpdate({ - email, - requestBody: { disabled: true } - }) - sendUserToast('User disabled') - listUsers(activeOnly) - } catch (e) { - sendUserToast('Failed to disable user', true) + {#if isServiceAccount} + {#if workspace_id} + Manage in workspace + {/if} + {:else} + + { + const btn = editWrappers[email]?.querySelector( + '[aria-label="Popup button"]' + ) + if (btn instanceof HTMLElement) btn.click() + } + }, + { + displayName: disabled ? 'Enable' : 'Disable', + icon: disabled ? CheckCircle2 : Ban, + action: () => { + if (!disabled) { + disableUserEmail = email + disableConfirmedCallback = async () => { + try { + await UserService.globalUserUpdate({ + email, + requestBody: { disabled: true } + }) + sendUserToast('User disabled') + listUsers(activeOnly) + } catch (e) { + sendUserToast('Failed to disable user', true) + } } + } else { + UserService.globalUserUpdate({ + email, + requestBody: { disabled: false } + }) + .then(() => { + sendUserToast('User enabled') + listUsers(activeOnly) + }) + .catch(() => { + sendUserToast('Failed to enable user', true) + }) } - } else { - UserService.globalUserUpdate({ - email, - requestBody: { disabled: false } - }) - .then(() => { - sendUserToast('User enabled') - listUsers(activeOnly) - }) - .catch(() => { - sendUserToast('Failed to enable user', true) - }) + } + }, + { + displayName: 'Reassign', + icon: ArrowRightLeft, + action: () => { + offboardingEmail = email + offboardingReassignOnly = true + } + }, + { + displayName: 'Remove', + icon: UserMinus, + type: 'delete', + action: () => { + offboardingEmail = email + offboardingReassignOnly = false } } - }, - { - displayName: 'Reassign', - icon: ArrowRightLeft, - action: () => { - offboardingEmail = email - offboardingReassignOnly = true - } - }, - { - displayName: 'Remove', - icon: UserMinus, - type: 'delete', - action: () => { - offboardingEmail = email - offboardingReassignOnly = false - } - } - ]} - /> + ]} + /> + {/if}