feat: add workspace-level service accounts (#8560)

* feat: add workspace-level service accounts (EE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* sqlx

* sqlx

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-03-27 19:23:03 +00:00
committed by GitHub
parent dc75b73edc
commit 3959fe8297
27 changed files with 814 additions and 274 deletions

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ",
"query": "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ",
"describe": {
"columns": [
{
@@ -50,11 +50,16 @@
},
{
"ordinal": 9,
"name": "super_admin",
"name": "is_service_account",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 11,
"name": "name",
"type_info": "Varchar"
}
@@ -76,8 +81,9 @@
true,
true,
false,
null,
true
]
},
"hash": "6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b"
"hash": "1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, is_service_account, disabled FROM usr WHERE username = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_service_account",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb"
}

View File

@@ -47,6 +47,11 @@
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
@@ -63,7 +68,8 @@
false,
false,
true,
true
true,
false
]
},
"hash": "5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c"

View File

@@ -47,6 +47,11 @@
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
@@ -64,7 +69,8 @@
false,
false,
true,
true
true,
false
]
},
"hash": "60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b"

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND (username = $2 OR email = $3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43"
}

View File

@@ -47,6 +47,11 @@
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
@@ -63,7 +68,8 @@
false,
false,
true,
true
true,
false
]
},
"hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2"

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, owner)\n VALUES ($1, $2, $3, $4, $5, $6, false, $7)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Timestamptz",
"Varchar"
]
},
"nullable": []
},
"hash": "f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, false, true, true)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853"
}

View File

@@ -1 +1 @@
5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc
208da6989ef606e4068663246903acbcaa90a9dc

View File

@@ -0,0 +1 @@
ALTER TABLE usr DROP COLUMN is_service_account;

View File

@@ -0,0 +1 @@
ALTER TABLE usr ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,2 @@
ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(50);
ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(50);

View File

@@ -0,0 +1,2 @@
ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(255);
ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(255);

View File

@@ -225,7 +225,15 @@ impl AuthCache {
t_hash,
w_id.as_ref(),
)
.map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label))
.map(|x| {
(
x.owner,
x.email,
x.super_admin,
x.scopes,
x.label,
)
})
.fetch_optional(&self.db)
.await
.ok()
@@ -234,7 +242,13 @@ impl AuthCache {
if let Some(user) = user_o {
let authed_o = {
match user {
(Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => {
(
Some(owner),
Some(email),
super_admin,
_,
label,
) if w_id.is_some() => {
let username_override = username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {

View File

@@ -1 +1,4 @@
pub mod users;
#[cfg(feature = "private")]
pub mod users_ee;
mod users_oss;

View File

@@ -58,7 +58,7 @@ use windmill_common::{
use windmill_common::{BASE_URL, HUB_BASE_URL};
use windmill_git_sync::handle_deployment_metadata;
const COOKIE_PATH: &str = "/";
pub const COOKIE_PATH: &str = "/";
pub fn workspaced_service() -> Router {
Router::new()
@@ -75,6 +75,11 @@ pub fn workspaced_service() -> Router {
.route("/whoami", get(whoami))
.route("/leave", post(leave_workspace))
.route("/username_to_email/{username}", get(username_to_email))
.route(
"/impersonate_service_account",
post(impersonate_service_account),
)
.route("/exit_impersonation", post(exit_impersonation))
}
pub fn global_service() -> Router {
@@ -135,6 +140,7 @@ pub struct User {
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added_via: Option<serde_json::Value>,
pub is_service_account: bool,
}
#[derive(Serialize)]
@@ -176,6 +182,7 @@ pub struct UserInfo {
pub folders: Vec<String>,
pub folders_owners: Vec<String>,
pub name: Option<String>,
pub is_service_account: bool,
}
#[derive(FromRow, Serialize)]
@@ -620,8 +627,9 @@ async fn is_valid_logout_redirect(rd: &str) -> bool {
async fn whoami(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed,
authed: ApiAuthed,
) -> JsonResult<UserInfo> {
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
let user = get_user(&w_id, &username, &db).await?;
if let Some(user) = user {
Ok(Json(user))
@@ -648,6 +656,7 @@ async fn whoami(
.into_iter()
.filter_map(|x| if x.2 { Some(x.0) } else { None })
.collect(),
is_service_account: false,
}))
}
}
@@ -663,11 +672,11 @@ async fn global_whoami(
email = $1",
email
)
.fetch_one(&db)
.fetch_optional(&db)
.await
.map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}")));
.map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}")))?;
if let Ok(user) = user {
if let Some(user) = user {
Ok(Json(user))
} else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) {
Ok(Json(GlobalUserInfo {
@@ -685,7 +694,21 @@ async fn global_whoami(
disabled: false,
}))
} else {
Err(user.unwrap_err())
// Service accounts don't have a password row
Ok(Json(GlobalUserInfo {
email: email.clone(),
login_type: Some("service_account".to_string()),
super_admin: false,
devops: false,
verified: true,
name: None,
company: None,
username: None,
operator_only: Some(true),
first_time_user: false,
role_source: "service_account".to_string(),
disabled: false,
}))
}
}
@@ -736,12 +759,13 @@ pub struct User2 {
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added_via: Option<serde_json::Value>,
pub is_service_account: bool,
}
async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo>> {
let user = sqlx::query_as!(
User2,
"SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2
"SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2
",
username,
w_id
@@ -782,6 +806,7 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo
.into_iter()
.filter_map(|x| if x.2 { Some(x.0) } else { None })
.collect(),
is_service_account: usr.is_service_account,
}))
}
@@ -2025,6 +2050,44 @@ async fn impersonate(
Ok((StatusCode::CREATED, token))
}
#[derive(Deserialize)]
pub struct ImpersonateServiceAccountRequest {
pub username: String,
}
async fn impersonate_service_account(
Extension(db): Extension<DB>,
authed: ApiAuthed,
cookies: Cookies,
Tokened { token: current_token }: Tokened,
Path(w_id): Path<String>,
Json(req): Json<ImpersonateServiceAccountRequest>,
) -> Result<(StatusCode, String)> {
crate::users_oss::impersonate_service_account(db, authed, cookies, current_token, w_id, req)
.await
}
#[derive(Deserialize)]
struct ExitImpersonationRequest {
token: String,
}
async fn exit_impersonation(
cookies: Cookies,
Json(req): Json<ExitImpersonationRequest>,
) -> Result<String> {
let mut cookie = tower_cookies::Cookie::new(COOKIE_NAME, req.token);
cookie.set_secure(IS_SECURE.read().await.clone());
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
cookie.set_http_only(true);
cookie.set_path(COOKIE_PATH);
if COOKIE_DOMAIN.is_some() {
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
}
cookies.add(cookie);
Ok("exited impersonation".to_string())
}
#[derive(Deserialize)]
struct ListTokenQuery {
exclude_ephemeral: Option<bool>,

View File

@@ -0,0 +1,28 @@
#[cfg(feature = "private")]
#[allow(unused)]
pub use crate::users_ee::*;
#[cfg(not(feature = "private"))]
use crate::users::ImpersonateServiceAccountRequest;
#[cfg(not(feature = "private"))]
use http::StatusCode;
#[cfg(not(feature = "private"))]
use tower_cookies::Cookies;
#[cfg(not(feature = "private"))]
use windmill_api_auth::ApiAuthed;
#[cfg(not(feature = "private"))]
use windmill_common::DB;
#[cfg(not(feature = "private"))]
pub async fn impersonate_service_account(
_db: DB,
_authed: ApiAuthed,
_cookies: Cookies,
_current_token: String,
_w_id: String,
_req: ImpersonateServiceAccountRequest,
) -> windmill_common::error::Result<(StatusCode, String)> {
Err(windmill_common::error::Error::BadRequest(
"Service accounts require Windmill Enterprise Edition".to_string(),
))
}

View File

@@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router {
.route("/archive", post(archive_workspace))
.route("/invite_user", post(invite_user))
.route("/add_user", post(add_user))
.route("/create_service_account", post(create_service_account))
.route("/delete_invite", post(delete_invite))
.route("/rebuild_dependency_map", post(rebuild_dependency_map))
.route("/get_dependency_map", get(get_dependency_map))
@@ -4232,6 +4233,20 @@ 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,
}
async fn create_service_account(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(nu): Json<NewServiceAccount>,
) -> Result<(StatusCode, String)> {
crate::workspaces_oss::create_service_account(authed, db, w_id, nu).await
}
async fn delete_invite(
ApiAuthed { username, is_admin, .. }: ApiAuthed,
Extension(db): Extension<DB>,

View File

@@ -3,7 +3,9 @@
pub use crate::workspaces_ee::*;
#[cfg(not(feature = "private"))]
use crate::workspaces::EditAutoInvite;
use crate::workspaces::{EditAutoInvite, NewServiceAccount};
#[cfg(not(feature = "private"))]
use http::StatusCode;
#[cfg(not(feature = "private"))]
use windmill_api_auth::ApiAuthed;
#[cfg(not(feature = "private"))]
@@ -20,3 +22,15 @@ pub async fn edit_auto_invite(
"Not implemented on OSS".to_string(),
))
}
#[cfg(not(feature = "private"))]
pub async fn create_service_account(
_authed: ApiAuthed,
_db: DB,
_w_id: String,
_nu: NewServiceAccount,
) -> windmill_common::error::Result<(StatusCode, String)> {
Err(windmill_common::error::Error::BadRequest(
"Service accounts require Windmill Enterprise Edition".to_string(),
))
}

View File

@@ -2125,6 +2125,87 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/create_service_account:
post:
summary: create a service account
operationId: createServiceAccount
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
required:
- username
responses:
"201":
description: service account created
content:
text/plain:
schema:
type: string
/w/{workspace}/users/impersonate_service_account:
post:
summary: impersonate a service account
operationId: impersonateServiceAccount
tags:
- user
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
required:
- username
responses:
"201":
description: impersonation token
content:
text/plain:
schema:
type: string
/w/{workspace}/users/exit_impersonation:
post:
summary: exit service account impersonation
operationId: exitImpersonation
tags:
- user
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
token:
type: string
required:
- token
responses:
"200":
description: exited impersonation
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/delete_invite:
post:
summary: delete user invite
@@ -20123,6 +20204,8 @@ components:
nullable: true
allOf:
- $ref: "#/components/schemas/UserSource"
is_service_account:
type: boolean
required:
- email
- username

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { globalEmailInvite, superadmin, workspaceStore } from '$lib/stores'
import { globalEmailInvite, superadmin, workspaceStore, enterpriseLicense } from '$lib/stores'
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
import { Button } from './common'
import Popover from './meltComponents/Popover.svelte'
@@ -10,6 +10,7 @@
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { UserPlus } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
const dispatch = createEventDispatcher()
@@ -32,45 +33,55 @@
getAutomateUsernameCreationSetting()
async function addUser() {
await WorkspaceService.addUser({
workspace: $workspaceStore!,
requestBody: {
email: email!,
username: automateUsernameCreation ? undefined : username,
is_admin: selected == 'admin',
operator: selected == 'operator'
}
})
sendUserToast(`Added ${email}`)
if (!(await UserService.existsEmail({ email: email! }))) {
let isSuperadmin = $superadmin
if (!isCloudHosted()) {
const emailCopy = email!
sendUserToast(
`User ${email} is not registered yet on the instance. ${
!isSuperadmin
? `If not using SSO, ask an administrator to add ${email} to the instance`
: ''
}`,
true,
isSuperadmin
? [
{
label: 'Add user to the instance',
callback: () => {
$globalEmailInvite = emailCopy
goto('#superadmin-settings')
if (selected === 'service_account') {
if (!username) return
await WorkspaceService.createServiceAccount({
workspace: $workspaceStore!,
requestBody: { username: username! }
})
sendUserToast(`Service account '${username}' created`)
} else {
await WorkspaceService.addUser({
workspace: $workspaceStore!,
requestBody: {
email: email!,
username: automateUsernameCreation ? undefined : username,
is_admin: selected == 'admin',
operator: selected == 'operator'
}
})
sendUserToast(`Added ${email}`)
if (!(await UserService.existsEmail({ email: email! }))) {
let isSuperadmin = $superadmin
if (!isCloudHosted()) {
const emailCopy = email!
sendUserToast(
`User ${email} is not registered yet on the instance. ${
!isSuperadmin
? `If not using SSO, ask an administrator to add ${email} to the instance`
: ''
}`,
true,
isSuperadmin
? [
{
label: 'Add user to the instance',
callback: () => {
$globalEmailInvite = emailCopy
goto('#superadmin-settings')
}
}
}
]
: []
)
]
: []
)
}
}
}
dispatch('new')
}
let selected: 'operator' | 'developer' | 'admin' = $state('developer')
let selected: 'operator' | 'developer' | 'admin' | 'service_account' = $state('developer')
let isServiceAccount = $derived(selected === 'service_account')
</script>
<Popover placement="bottom-end">
@@ -80,15 +91,27 @@
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col w-72 p-4">
<div class="flex flex-col w-[28rem] p-4">
<span class="text-sm mb-2 leading-6 font-semibold">Add a new user</span>
<span class="text-xs mb-1 leading-6">Email</span>
<input type="email mb-1" onkeyup={handleKeyUp} placeholder="email" bind:value={email} />
{#if isServiceAccount}
<span class="text-xs mb-1 leading-6">Username</span>
<input
type="text"
onkeyup={handleKeyUp}
placeholder="my_service_account"
autocomplete="off"
data-1p-ignore
bind:value={username}
/>
{:else}
<span class="text-xs mb-1 leading-6">Email</span>
<input type="email mb-1" onkeyup={handleKeyUp} placeholder="email" bind:value={email} />
{#if !automateUsernameCreation}
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
<input type="text" onkeyup={handleKeyUp} placeholder="username" bind:value={username} />
{#if !automateUsernameCreation}
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
<input type="text" onkeyup={handleKeyUp} placeholder="username" bind:value={username} />
{/if}
{/if}
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
@@ -112,6 +135,13 @@
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
<ToggleButton
value="service_account"
label={$enterpriseLicense ? 'Service Account' : 'Service Account (EE)'}
tooltip="A service account is a workspace-scoped identity for automation. It cannot log in directly and can be impersonated by admins."
disabled={!$enterpriseLicense}
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button
@@ -125,7 +155,9 @@
username = undefined
})
}}
disabled={email === undefined || (!automateUsernameCreation && username === undefined)}
disabled={isServiceAccount
? username === undefined || username === ''
: email === undefined || (!automateUsernameCreation && username === undefined)}
>
Add
</Button>

View File

@@ -14,9 +14,15 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import type { CancelablePromise, User, UserUsage } from '$lib/gen'
import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen'
import { userStore, workspaceStore, superadmin, globalEmailInvite } from '$lib/stores'
import {
userStore,
workspaceStore,
superadmin,
globalEmailInvite,
enterpriseLicense
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Loader2, Mails, Search, Plus, UserMinus, X } from 'lucide-svelte'
import { Loader2, Mails, Search, Plus, UserMinus, X, Bot, LogIn } from 'lucide-svelte'
import Select from '$lib/components/select/Select.svelte'
import SearchItems from '../SearchItems.svelte'
import Cell from '../table/Cell.svelte'
@@ -45,6 +51,8 @@
let selectedNewInstanceGroup: string | undefined = $state(undefined)
let selectedNewRole: string | undefined = $state('developer')
// Service account creation
// Available groups for dropdowns - filter out already configured groups
let availableGroupItems = $derived(
instanceGroups
@@ -488,12 +496,14 @@
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
@@ -595,18 +605,21 @@
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
@@ -663,18 +676,21 @@
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
@@ -779,8 +795,21 @@
</tr>
{/if}
<tr class={index % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'}>
<Cell first><a href="mailto:{email}">{truncate(email, 20)}</a></Cell>
<Cell>{truncate(username, 30)}</Cell>
<Cell first>
{#if user.is_service_account}
<span class="flex items-center gap-1.5 max-w-[150px]" title={email}>
<Bot size={16} class="text-blue-500 shrink-0" />
<span class="truncate">{email}</span>
</span>
{:else}
<a href="mailto:{email}" class="block truncate max-w-[150px]" title={email}
>{email}</a
>
{/if}
</Cell>
<Cell
><span class="block truncate max-w-[120px]" title={username}>{username}</span></Cell
>
{#if hasNonManualUsers}
<Cell>
<div class="flex items-center gap-2">
@@ -796,14 +825,21 @@
</Cell>
{/if}
<Cell
>{#if usage?.[email] != undefined}{usage?.[email]}{:else}<Loader2
>{#if usage != undefined}{usage[email] ?? 0}{:else}<Loader2
size={14}
class="animate-spin"
/>{/if}</Cell
>
<Cell>
<div>
{#if added_via?.source === 'instance_group'}
{#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'}
<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'}
@@ -840,6 +876,7 @@
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
@@ -847,6 +884,7 @@
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
@@ -854,6 +892,7 @@
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
@@ -887,6 +926,32 @@
</Cell>
<Cell>
<div class="flex gap-1">
{#if user.is_service_account && $userStore?.is_admin}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: LogIn }}
disabled={!$enterpriseLicense}
title={!$enterpriseLicense ? 'Requires Enterprise Edition' : undefined}
onClick={async () => {
try {
// Backend sets the impersonation cookie and returns the old token
const oldToken = await UserService.impersonateServiceAccount({
workspace: $workspaceStore ?? '',
requestBody: { username }
})
if (oldToken) {
sessionStorage.setItem('pre_impersonation_token', oldToken)
}
window.location.href = '/'
} catch (e) {
sendUserToast('Failed to impersonate service account', true)
}
}}
>
Impersonate
</Button>
{/if}
{#snippet removeUserButton(disabled: boolean)}
<Button
unifiedSize="sm"
@@ -1005,6 +1070,7 @@
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
@@ -1012,6 +1078,7 @@
<ToggleButton
value="developer"
small
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
@@ -1019,6 +1086,7 @@
<ToggleButton
value="admin"
small
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}

View File

@@ -18,10 +18,12 @@
import { base } from '$lib/base'
import MultiplayerMenu from './MultiplayerMenu.svelte'
import { Plus } from 'lucide-svelte'
import {
clearWorkspaceFromStorage,
enterpriseLicense,
superadmin,
usedTriggerKinds,
userWorkspaces,
workspaceStore,
tutorialsToDo,
@@ -35,9 +37,10 @@
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton, { sidebarClasses } from './MenuButton.svelte'
import MenuLink from './MenuLink.svelte'
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
import type { FavoriteKind } from './FavoriteMenu.svelte'
let darkMode: boolean = $state(false)
let showExtraTriggers = $state(false)
let menubarEl: HTMLElement | undefined = $state()
interface Props {
isCollapsed?: boolean
@@ -114,55 +117,6 @@
label: 'Workers',
id: 'workers',
href: `${base}/workers`
}
].filter(filterLink)
)
let secondMenuTriggerLinks = $derived(
[
{
label: 'Custom HTTP routes',
id: 'triggers',
href: `${base}/routes`
},
{
label: 'Websocket triggers',
id: 'triggers',
href: `${base}/websocket_triggers`
},
{
label: 'Postgres triggers',
id: 'triggers',
href: `${base}/postgres_triggers`
},
{
label: 'Kafka triggers',
id: 'triggers',
href: `${base}/kafka_triggers`
},
{
label: 'NATS triggers',
id: 'triggers',
href: `${base}/nats_triggers`
},
{
label: 'SQS triggers',
id: 'triggers',
href: `${base}/sqs_triggers`
},
{
label: 'GCP Pub/Sub triggers',
id: 'triggers',
href: `${base}/gcp_triggers`
},
{
label: 'MQTT triggers',
id: 'triggers',
href: `${base}/mqtt_triggers`
},
{
label: 'Email triggers',
id: 'triggers',
href: `${base}/email_triggers`
},
{
label: 'Audit logs',
@@ -171,178 +125,250 @@
}
].filter(filterLink)
)
let showMore = $state(false)
type TriggerMenuLink = SecondMenuLink & { kind: string }
let allTriggerLinks: TriggerMenuLink[] = $derived(
(
[
{ label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' },
{
label: 'Websocket triggers',
id: 'triggers',
href: `${base}/websocket_triggers`,
kind: 'ws'
},
{
label: 'Postgres triggers',
id: 'triggers',
href: `${base}/postgres_triggers`,
kind: 'postgres'
},
{ label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' },
{ label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' },
{ label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' },
{
label: 'GCP Pub/Sub triggers',
id: 'triggers',
href: `${base}/gcp_triggers`,
kind: 'gcp'
},
{ label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' },
{ label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' }
] as TriggerMenuLink[]
).filter(filterLink)
)
let secondMenuTriggerLinks = $derived(
allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind))
)
let extraTriggerLinks = $derived(
allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind))
)
</script>
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside on:close={() => (showMore = false)}>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-xs"
icon={MenuIcon}
{isCollapsed}
lightMode
label={undefined}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
<div class="w-full max-w-full">
{#each favoriteLinks ?? [] as favorite (favorite.href)}
<MenuItem
href={favorite.href}
{item}
class={twMerge(
'w-full inline-flex flex-row px-2 py-2 hover:bg-surface-hover',
'data-[highlighted]:bg-surface-hover'
)}
>
<span class="center-center">
{#if favorite.kind == 'script'}
<Code2 size={16} />
{:else if favorite.kind == 'flow'}
<BarsStaggered size={16} />
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
<LayoutDashboard size={16} />
{:else if favorite.kind == 'asset'}
<Table2 size={16} />
{/if}
</span>
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
{favorite.label}
</span>
</MenuItem>
{/each}
</div>
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} {item} lightMode />
{/each}
<div class="divide-y" role="none">
<div role="none">
<MenuItem
href={USER_SETTINGS_HASH}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2',
sidebarClasses.text,
sidebarClasses.hoverBg
)}
lightMode
{item}
>
<Settings size={14} />
Account settings
</MenuItem>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menubarEl}
onmouseenter={() => {
const btn = menubarEl?.querySelector('[data-melt-menubar-trigger]')
if (btn instanceof HTMLElement) btn.click()
}}
>
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside on:close={() => (showExtraTriggers = false)}>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-xs bg-surface !pl-3.5 !pr-2 !w-auto"
icon={MenuIcon}
isCollapsed={false}
lightMode
label={undefined}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
<div class="w-full max-w-full">
{#each favoriteLinks ?? [] as favorite (favorite.href)}
<MenuItem
href={favorite.href}
{item}
class={twMerge(
'w-full inline-flex flex-row px-2 py-2 hover:bg-surface-hover',
'data-[highlighted]:bg-surface-hover'
)}
>
<span class="center-center">
{#if favorite.kind == 'script'}
<Code2 size={16} />
{:else if favorite.kind == 'flow'}
<BarsStaggered size={16} />
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
<LayoutDashboard size={16} />
{:else if favorite.kind == 'asset'}
<Table2 size={16} />
{/if}
</span>
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
{favorite.label}
</span>
</MenuItem>
{/each}
</div>
<div role="none">
<MenuItem
onClick={() => {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}}
lightMode
class={twMerge(
'w-full flex gap-3.5 px-2 py-2',
sidebarClasses.hoverBg,
sidebarClasses.text
)}
{item}
>
{#if darkMode}
<Sun size={14} />
{:else}
<Moon size={14} />
{/if}
Switch theme
</MenuItem>
<MenuItem
href="{base}/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
lightMode
class={twMerge('flex gap-3.5 px-2 py-2', sidebarClasses.hoverBg, sidebarClasses.text)}
{item}
>
<Building size={14} />
All workspaces
</MenuItem>
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} {item} lightMode />
{/each}
{#if $superadmin}
<div class="divide-y" role="none">
<div role="none">
<MenuItem
href="#superadmin-settings"
href={USER_SETTINGS_HASH}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 ',
'text-secondary text-xs',
'hover:bg-surface-hover hover:text-primary cursor-pointer',
'flex flex-row gap-3.5 items-center px-2 py-2',
sidebarClasses.text,
sidebarClasses.hoverBg
)}
lightMode
{item}
>
<Settings size={14} />
Account settings
</MenuItem>
</div>
<div role="none">
<MenuItem
onClick={() => {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}}
lightMode
class={twMerge(
'w-full flex gap-3.5 px-2 py-2',
sidebarClasses.hoverBg,
sidebarClasses.text
)}
{item}
>
{#if darkMode}
<Sun size={14} />
{:else}
<Moon size={14} />
{/if}
Switch theme
</MenuItem>
<MenuItem
href="{base}/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
lightMode
class={twMerge(
'flex gap-3.5 px-2 py-2',
sidebarClasses.hoverBg,
sidebarClasses.text
)}
{item}
>
<Building size={14} />
All workspaces
</MenuItem>
{#if $superadmin}
<MenuItem
href="#superadmin-settings"
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 ',
'text-secondary text-xs',
'hover:bg-surface-hover hover:text-primary cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<ServerCog size={14} />
Instance settings
</MenuItem>
{/if}
<MenuItem
onClick={() => logout()}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
'text-primary text-xs',
'hover:bg-surface-hover cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<ServerCog size={14} />
Instance settings
<LogOut size={14} />
Sign out
</MenuItem>
{/if}
<MenuItem
onClick={() => logout()}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
'text-primary text-xs',
'hover:bg-surface-hover cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<LogOut size={14} />
Sign out
</MenuItem>
</div>
<div onmouseleave={() => (showMore = false)} role="none">
{#if secondMenuLinks.length}
<ResizeTransitionWrapper vertical innerClass="w-full">
{#if !showMore}
<div onmouseenter={() => (showMore = true)} role="none">
<MenuItem {item}>
<div class="px-2 py-2 text-primary text-2xs">More...</div>
</MenuItem>
</div>
{:else}
{#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])}
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
</div>
<div role="none">
{#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])}
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
{menuLink.label}
</MenuItem>
{/each}
{/snippet}
{#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length}
<div class="divide-y">
{#if secondMenuLinks.length}<div
>{@render renderSecondMenuLinks(secondMenuLinks)}</div
>{/if}
{#if secondMenuTriggerLinks.length}<div
>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div
>{/if}
{#if extraTriggerLinks.length}<div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-row gap-3.5 items-center px-2 py-2 w-full text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation()
showExtraTriggers = !showExtraTriggers
}}
>
{menuLink.label}
</MenuItem>
{/each}
{/snippet}
<div class="divide-y">
<div>{@render renderSecondMenuLinks(secondMenuLinks)}</div>
<div>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div>
</div>
{/if}
</ResizeTransitionWrapper>
{/if}
{#if $enterpriseLicense}
<MultiplayerMenu />
{/if}
<Plus size={12} />
<span class="text-2xs">More triggers</span>
</div>
{#if showExtraTriggers}
{#each extraTriggerLinks as menuLink (menuLink.href)}
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 pl-6 text-tertiary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
{menuLink.label}
</MenuItem>
{/each}
{/if}
</div>{/if}
</div>
{/if}
{#if $enterpriseLicense}
<MultiplayerMenu />
{/if}
</div>
</div>
</div>
{/snippet}
</Menu>
{/snippet}
</Menubar>
{/snippet}
</Menu>
{/snippet}
</Menubar>
</div>
<DarkModeObserver bind:darkMode />

View File

@@ -28,6 +28,7 @@ export interface UserExt {
pgroups: string[]
folders: string[]
folders_owners: string[]
is_service_account?: boolean
}
export interface UserWorkspace {

View File

@@ -676,7 +676,7 @@
</div>
{/if}
{:else}
<div class="absolute top-2 left-2 z5000">
<div class="absolute top-1 left-1 z5000">
<OperatorMenu favoriteLinks={favoriteManager.current} />
</div>
{/if}
@@ -775,6 +775,38 @@
</div>
{/if}
<div class="flex flex-col h-full w-full">
{#if $userStore?.is_service_account}
<div
class="bg-yellow-100 dark:bg-yellow-900/50 border-b border-yellow-300 dark:border-yellow-700 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200 flex items-center justify-center gap-4 shrink-0"
>
<span>
Viewing workspace on behalf of <strong>{$userStore.username}</strong>
<span class="text-yellow-600 dark:text-yellow-400"
>(impersonated by {$userStore.impersonating_email})</span
>
</span>
<button
class="px-3 py-1 text-xs font-medium bg-yellow-200 dark:bg-yellow-800 hover:bg-yellow-300 dark:hover:bg-yellow-700 rounded transition-colors"
onclick={async () => {
const savedToken = sessionStorage.getItem('pre_impersonation_token')
if (savedToken && $workspaceStore) {
try {
await UserService.exitImpersonation({
workspace: $workspaceStore,
requestBody: { token: savedToken }
})
} catch (e) {
console.error('Failed to exit impersonation', e)
}
sessionStorage.removeItem('pre_impersonation_token')
}
window.location.href = '/workspace_settings?tab=users'
}}
>
Exit impersonation
</button>
</div>
{/if}
<AiChatLayout
{children}
noPadding={devOnly}