diff --git a/backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json similarity index 75% rename from backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json rename to backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json index 6e1b36a97c..b4a9f19f45 100644 --- a/backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json +++ b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json @@ -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" } diff --git a/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json b/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json new file mode 100644 index 0000000000..2086242e9c --- /dev/null +++ b/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json @@ -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" +} diff --git a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json b/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json index 09775dcc3a..79625b6baf 100644 --- a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json +++ b/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json @@ -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" diff --git a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json index 3a635ab004..ed09f2833f 100644 --- a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json +++ b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json @@ -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" diff --git a/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json b/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json new file mode 100644 index 0000000000..cd1d6810cd --- /dev/null +++ b/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json @@ -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" +} diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json new file mode 100644 index 0000000000..5661c59faf --- /dev/null +++ b/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json @@ -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" +} diff --git a/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json b/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json new file mode 100644 index 0000000000..2c00759a63 --- /dev/null +++ b/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json @@ -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" +} diff --git a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json index cdeb30f672..7be961c050 100644 --- a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json +++ b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json @@ -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" diff --git a/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json b/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json new file mode 100644 index 0000000000..24d9cd9517 --- /dev/null +++ b/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json @@ -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" +} diff --git a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json new file mode 100644 index 0000000000..117a8bdc1b --- /dev/null +++ b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json @@ -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" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d0328f83f3..4751795cd9 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc +208da6989ef606e4068663246903acbcaa90a9dc \ No newline at end of file diff --git a/backend/migrations/20260326200000_service_accounts.down.sql b/backend/migrations/20260326200000_service_accounts.down.sql new file mode 100644 index 0000000000..88a50692ab --- /dev/null +++ b/backend/migrations/20260326200000_service_accounts.down.sql @@ -0,0 +1 @@ +ALTER TABLE usr DROP COLUMN is_service_account; diff --git a/backend/migrations/20260326200000_service_accounts.up.sql b/backend/migrations/20260326200000_service_accounts.up.sql new file mode 100644 index 0000000000..b9e96d0baa --- /dev/null +++ b/backend/migrations/20260326200000_service_accounts.up.sql @@ -0,0 +1 @@ +ALTER TABLE usr ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/migrations/20260327000000_email_varchar_255.down.sql b/backend/migrations/20260327000000_email_varchar_255.down.sql new file mode 100644 index 0000000000..b5ff4d22c2 --- /dev/null +++ b/backend/migrations/20260327000000_email_varchar_255.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(50); +ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(50); diff --git a/backend/migrations/20260327000000_email_varchar_255.up.sql b/backend/migrations/20260327000000_email_varchar_255.up.sql new file mode 100644 index 0000000000..95adb957b3 --- /dev/null +++ b/backend/migrations/20260327000000_email_varchar_255.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(255); +ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(255); diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index f10ae321b9..b5b04cb3e8 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -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" { diff --git a/backend/windmill-api-users/src/lib.rs b/backend/windmill-api-users/src/lib.rs index 913bd46b82..ee5369e616 100644 --- a/backend/windmill-api-users/src/lib.rs +++ b/backend/windmill-api-users/src/lib.rs @@ -1 +1,4 @@ pub mod users; +#[cfg(feature = "private")] +pub mod users_ee; +mod users_oss; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index b3b32859c7..4bb61183ce 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, + pub is_service_account: bool, } #[derive(Serialize)] @@ -176,6 +182,7 @@ pub struct UserInfo { pub folders: Vec, pub folders_owners: Vec, pub name: Option, + 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, Path(w_id): Path, - ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed, + authed: ApiAuthed, ) -> JsonResult { + 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, #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, + pub is_service_account: bool, } async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { 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, + authed: ApiAuthed, + cookies: Cookies, + Tokened { token: current_token }: Tokened, + Path(w_id): Path, + Json(req): Json, +) -> 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, +) -> Result { + 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, diff --git a/backend/windmill-api-users/src/users_oss.rs b/backend/windmill-api-users/src/users_oss.rs new file mode 100644 index 0000000000..a42cce8405 --- /dev/null +++ b/backend/windmill-api-users/src/users_oss.rs @@ -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(), + )) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 08b9d4d3d6..db09f16393 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, + Path(w_id): Path, + Json(nu): Json, +) -> 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, diff --git a/backend/windmill-api-workspaces/src/workspaces_oss.rs b/backend/windmill-api-workspaces/src/workspaces_oss.rs index da46622c26..872061e554 100644 --- a/backend/windmill-api-workspaces/src/workspaces_oss.rs +++ b/backend/windmill-api-workspaces/src/workspaces_oss.rs @@ -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(), + )) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index aa3505eec7..082298ab03 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 42f0e0430a..c537740480 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -1,6 +1,6 @@ @@ -80,15 +91,27 @@ {/snippet} {#snippet content()} -
+
Add a new user - Email - + {#if isServiceAccount} + Username + + {:else} + Email + - {#if !automateUsernameCreation} - Username - + {#if !automateUsernameCreation} + Username + + {/if} {/if} Role @@ -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} /> + {/snippet} diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 03b57f6d9a..d7221248ee 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -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 })} {/if} - {truncate(email, 20)} - {truncate(username, 30)} + + {#if user.is_service_account} + + + {email} + + {:else} + {email} + {/if} + + {username} {#if hasNonManualUsers}
@@ -796,14 +825,21 @@ {/if} {#if usage?.[email] != undefined}{usage?.[email]}{:else}{#if usage != undefined}{usage[email] ?? 0}{:else}{/if}
- {#if added_via?.source === 'instance_group'} + {#if user.is_service_account} +
+ + Operator + + Service accounts are always operators. +
+ {:else if added_via?.source === 'instance_group'}
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} @@ -840,6 +876,7 @@ {#snippet children({ item })}
+ {#if user.is_service_account && $userStore?.is_admin} + + {/if} {#snippet removeUserButton(disabled: boolean)}
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 22251f6aa6..7084b9a951 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -28,6 +28,7 @@ export interface UserExt { pgroups: string[] folders: string[] folders_owners: string[] + is_service_account?: boolean } export interface UserWorkspace { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index b384b11a1e..43372d8337 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -676,7 +676,7 @@
{/if} {:else} -
+
{/if} @@ -775,6 +775,38 @@
{/if}
+ {#if $userStore?.is_service_account} +
+ + Viewing workspace on behalf of {$userStore.username} + (impersonated by {$userStore.impersonating_email}) + + +
+ {/if}