feat(service-accounts): allow choosing role at creation time (#9307)

* [ee] feat(service-accounts): allow choosing role at creation time

Previously, service accounts were hardcoded to operator and could not be
used as the CLI sync user since they had no write access. They also only
counted as 0.5 seat each.

This change:
- Extends `NewServiceAccount` to accept optional `is_admin` / `operator`
  (defaults to `operator=true` for backward compatibility).
- Exposes a role picker in `AddUser.svelte` when creating a service
  account (Operator / Developer / Admin).
- Lets admins update a service account's role from the user list (it
  used to be locked to "Operator" with a tooltip).
- Updates the OpenAPI spec + regenerates the frontend client.

A developer/admin service account counts as 1 seat under the existing
seat-cap logic (operators stay at 0.5).

Companion PR on windmill-ee-private updates the `INSERT INTO usr` to
honour the chosen role.

Fixes WIN-1985

* [ee] feat(service-accounts): wm_deployers opt-in for Dev role

When creating a service account with role=Developer, surface a toggle
"Add to wm_deployers" (recommended). Members of wm_deployers can deploy
on behalf of other users — the typical setup when the service account is
used as the CLI sync / CI deploy identity.

- `NewServiceAccount` gains an optional `add_to_deployers` flag.
- Frontend defaults the toggle to on but only shows it under Developer
  (admins have it implicitly; operators can't deploy).
- Tooltip links to docs.windmill.dev "Run on behalf of".

Companion EE PR updates the handler to INSERT into usr_to_group for
wm_deployers when the flag is set.

Refs WIN-1985

* chore: update ee-repo-ref to 974ed42067d9f63acb42332b671b8c01ffd4b625

This commit updates the EE repository reference after PR #589 was merged in windmill-ee-private.

Previous ee-repo-ref: f7dbc3cc2ba21c396f4828881e3b9d9ab6f50c69

New ee-repo-ref: 974ed42067d9f63acb42332b671b8c01ffd4b625

Automated by sync-ee-ref workflow.

* [ee] fix(service-accounts): unhardcode role in superadmin user list

Two review issues from the merged #9307 / #589:

1. P1 — The global Users tab in #superadmin-settings still pinned every
   service account to "Operator". Now it shows the actual role
   (Admin / Operator / Developer), derived from the SA's usr row.

   - `list_users_as_super_admin`: replaced `true as operator_only` with
     the real `operator` value, and added `is_workspace_admin` from the
     row (NULL for password users since their admin status is
     per-workspace).
   - `global_whoami`: when the email belongs to a service account, look
     up its real `operator` / `is_admin` instead of pinning to operator.
   - `SuperadminSettingsInner.svelte`: drop the hardcoded "Operator"
     badge; render Admin / Operator / Developer using the new fields,
     matching the workspace-level view.

2. P2 — Regenerate the bundled `openapi-deref.{yaml,json}` so the
   `createServiceAccount` body (now exposing `is_admin`, `operator`,
   `add_to_deployers`) and the new `GlobalUserInfo.is_workspace_admin`
   field show up at runtime in `/api/openapi.{yaml,json}`.

Bumps `ee-repo-ref.txt` to the EE follow-up that adds the offline
seat-cap check on `create_service_account`.

Refs WIN-1985

* chore: update ee-repo-ref to b7a6068c1f3dc845e012959268b2426f0de4d697

This commit updates the EE repository reference after PR #590 was merged in windmill-ee-private.

Previous ee-repo-ref: 0b1307c21d1bfd6fb43a03c2ba39d2a8bf8e6470

New ee-repo-ref: b7a6068c1f3dc845e012959268b2426f0de4d697

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-06-08 11:35:45 +02:00
committed by tristantr
co-authored by windmill-internal-app[bot]
parent c161aff933
commit a5c7a7cd79
14 changed files with 4262 additions and 1558 deletions
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429"
}
@@ -0,0 +1,100 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, 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": "is_workspace_admin",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "first_time_user",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null,
false,
false,
false,
true,
true,
true,
null,
null,
false,
false,
false,
null
]
},
"hash": "1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1"
}
@@ -0,0 +1,101 @@
{
"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, NULL::bool as is_workspace_admin, 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!\", operator as operator_only, is_admin as is_workspace_admin, '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": "is_workspace_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000"
}
@@ -0,0 +1,101 @@
{
"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, NULL::bool as is_workspace_admin, 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, operator as operator_only, is_admin as is_workspace_admin, 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": "is_workspace_admin",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 13,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, $4, $5, true)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28"
}
+1 -1
View File
@@ -1 +1 @@
da5189cf69a453de3855057f41be0d84e5910707
b7a6068c1f3dc845e012959268b2426f0de4d697
+24 -7
View File
@@ -207,6 +207,11 @@ pub struct GlobalUserInfo {
username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
operator_only: Option<bool>,
/// Populated only for service-account rows (which are workspace-scoped).
/// `None` for password users since their admin status varies per workspace
/// and is not surfaced by this aggregation.
#[serde(skip_serializing_if = "Option::is_none")]
is_workspace_admin: Option<bool>,
first_time_user: bool,
role_source: String,
disabled: bool,
@@ -455,11 +460,11 @@ async fn list_users_as_super_admin(
GlobalUserInfo,
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 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
SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, 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)
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
SELECT email as "email!", operator as operator_only, is_admin as is_workspace_admin, '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
@@ -472,9 +477,9 @@ async fn list_users_as_super_admin(
} else {
sqlx::query_as!(
GlobalUserInfo,
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
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, NULL::bool as is_workspace_admin, 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
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, operator as operator_only, is_admin as is_workspace_admin, 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!"
@@ -727,7 +732,7 @@ async fn global_whoami(
) -> JsonResult<GlobalUserInfo> {
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, NULL::text as workspace_id FROM password WHERE \
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \
email = $1",
email
)
@@ -748,13 +753,24 @@ async fn global_whoami(
company: None,
username: None,
operator_only: None,
is_workspace_admin: None,
first_time_user: false,
role_source: "manual".to_string(),
disabled: false,
workspace_id: None,
}))
} else {
// Service accounts don't have a password row
// Service accounts don't have a password row. The SA email is unique
// per (workspace, username) and pinpoints a single usr row, so we can
// surface its real role rather than pinning to operator.
let sa_role = sqlx::query!(
"SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1",
email
)
.fetch_optional(&db)
.await
.map_err(|e| Error::internal_err(format!("fetching service-account role: {e:#}")))?;
Ok(Json(GlobalUserInfo {
email: email.clone(),
login_type: Some("service_account".to_string()),
@@ -764,7 +780,8 @@ async fn global_whoami(
name: None,
company: None,
username: None,
operator_only: Some(true),
operator_only: sa_role.as_ref().map(|r| r.operator).or(Some(true)),
is_workspace_admin: sa_role.as_ref().map(|r| r.is_admin),
first_time_user: false,
role_source: "service_account".to_string(),
disabled: false,
@@ -5451,6 +5451,16 @@ If you do not have an account on {}, login with SSO or ask an admin to create an
#[derive(Deserialize)]
pub struct NewServiceAccount {
pub username: String,
#[serde(default)]
pub is_admin: bool,
#[serde(default = "default_true")]
pub operator: bool,
#[serde(default)]
pub add_to_deployers: bool,
}
fn default_true() -> bool {
true
}
async fn create_service_account(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -2816,6 +2816,15 @@ paths:
properties:
username:
type: string
is_admin:
type: boolean
description: Grant the service account workspace admin. Defaults to false. Cannot be combined with operator=true.
operator:
type: boolean
description: Make the service account an operator. Defaults to true for backward compatibility. Set to false to count as a developer (1 seat) instead of 0.5 seat.
add_to_deployers:
type: boolean
description: Add the service account to the workspace `wm_deployers` group on creation. Recommended when the account will be used as a CLI sync / CI deploy identity so it can deploy on behalf of other users.
required:
- username
responses:
@@ -26602,6 +26611,9 @@ components:
type: string
operator_only:
type: boolean
is_workspace_admin:
type: boolean
description: Populated only for service accounts. True if the service account has workspace admin in its (single) workspace.
first_time_user:
type: boolean
role_source:
+57 -1
View File
@@ -9,6 +9,8 @@
import { goto } from '$lib/navigation'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import { UserPlus } from 'lucide-svelte'
const dispatch = createEventDispatcher()
@@ -36,7 +38,12 @@
if (!username) return
await WorkspaceService.createServiceAccount({
workspace: $workspaceStore!,
requestBody: { username: username! }
requestBody: {
username: username!,
is_admin: serviceAccountRole === 'admin',
operator: serviceAccountRole === 'operator',
add_to_deployers: serviceAccountRole === 'developer' && addToDeployers
}
})
sendUserToast(`Service account '${username}' created`)
} else {
@@ -80,7 +87,10 @@
}
type UserRole = 'operator' | 'developer' | 'admin' | 'service_account'
type ServiceAccountRole = 'operator' | 'developer' | 'admin'
let selected: UserRole = $state('developer' as UserRole)
let serviceAccountRole: ServiceAccountRole = $state('operator' as ServiceAccountRole)
let addToDeployers: boolean = $state(true)
let isServiceAccount = $derived(selected === 'service_account')
</script>
@@ -144,6 +154,52 @@
/>
{/snippet}
</ToggleButtonGroup>
{#if isServiceAccount}
<span class="text-xs mb-1 leading-6">Service account role</span>
<ToggleButtonGroup bind:selected={serviceAccountRole} class="mb-4">
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="Read/run only. Counts as 0.5 seat. Cannot be used for CLI sync or to author scripts/flows/apps."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="Can author and edit scripts/flows/apps within its path. Counts as 1 seat. Use this for CLI sync tokens."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="Full workspace admin. Counts as 1 seat. Grant only when the service account needs to manage workspace settings."
{item}
/>
{/snippet}
</ToggleButtonGroup>
{#if serviceAccountRole === 'developer'}
<div class="flex items-center gap-2 mb-4">
<Toggle bind:checked={addToDeployers} size="xs" />
<span class="text-xs leading-6">
Add to <code>wm_deployers</code>
<Tooltip>
Recommended when this service account will be used as a <code>wmill sync push</code>
/ CI deploy identity. Members of <code>wm_deployers</code> can deploy on behalf of
other users in the target workspace.
<a
href="https://www.windmill.dev/docs/core_concepts/staging_prod#run-on-behalf-of"
target="_blank"
rel="noopener noreferrer"
class="underline">Learn more</a
>.
</Tooltip>
</span>
</div>
{/if}
{/if}
<Button
variant="accent"
size="sm"
@@ -405,7 +405,7 @@
</Head>
<tbody>
{#if filteredUsers && users}
{#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 ?? ''))}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, is_workspace_admin, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
{@const isServiceAccount = login_type === 'service_account'}
<tr
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
@@ -467,7 +467,9 @@
>
{#if activeOnly}
<Cell>
{#if operator_only}
{#if is_workspace_admin}
Admin
{:else if operator_only}
Operator only
{:else}
Developer
@@ -480,9 +482,15 @@
<span
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
>
Operator
{is_workspace_admin
? 'Admin'
: operator_only
? 'Operator'
: 'Developer'}
</span>
<Tooltip>Service accounts are always operators.</Tooltip>
<Tooltip>
Service-account role is managed in the workspace user settings.
</Tooltip>
</div>
{:else}
<div class="flex flex-col items-start">
@@ -848,14 +848,7 @@
>
<Cell>
<div>
{#if user.is_service_account}
<div class="flex items-center gap-1">
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
Operator
</span>
<Tooltip>Service accounts are always operators.</Tooltip>
</div>
{:else if added_via?.source === 'instance_group'}
{#if added_via?.source === 'instance_group'}
<div class="flex items-center gap-1">
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}