diff --git a/backend/.sqlx/query-0a686ca61444d7ad7484071727aa039a6ea6697e5a49a633b767c052aa3e0a18.json b/backend/.sqlx/query-0a686ca61444d7ad7484071727aa039a6ea6697e5a49a633b767c052aa3e0a18.json index aa608ecc57..053857a0a0 100644 --- a/backend/.sqlx/query-0a686ca61444d7ad7484071727aa039a6ea6697e5a49a633b767c052aa3e0a18.json +++ b/backend/.sqlx/query-0a686ca61444d7ad7484071727aa039a6ea6697e5a49a633b767c052aa3e0a18.json @@ -48,7 +48,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-254776959f60455a00a2c29a1783ee6b27c836197df6454d3bb5c59441c34f44.json b/backend/.sqlx/query-254776959f60455a00a2c29a1783ee6b27c836197df6454d3bb5c59441c34f44.json index b2421d64f2..03bd3e00c1 100644 --- a/backend/.sqlx/query-254776959f60455a00a2c29a1783ee6b27c836197df6454d3bb5c59441c34f44.json +++ b/backend/.sqlx/query-254776959f60455a00a2c29a1783ee6b27c836197df6454d3bb5c59441c34f44.json @@ -69,7 +69,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-260feb784bb0b223bd9276d6a82bd26be90efb17a4323b9673e93ff88513942a.json b/backend/.sqlx/query-260feb784bb0b223bd9276d6a82bd26be90efb17a4323b9673e93ff88513942a.json index d3ccc22ef2..38795ced69 100644 --- a/backend/.sqlx/query-260feb784bb0b223bd9276d6a82bd26be90efb17a4323b9673e93ff88513942a.json +++ b/backend/.sqlx/query-260feb784bb0b223bd9276d6a82bd26be90efb17a4323b9673e93ff88513942a.json @@ -28,7 +28,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-2f42460fdd8aa125c8fd46b3cd02e47f57de0f073d3ce3bc7d21a7e404a83b5c.json b/backend/.sqlx/query-2f42460fdd8aa125c8fd46b3cd02e47f57de0f073d3ce3bc7d21a7e404a83b5c.json index c9110033a5..030a85c000 100644 --- a/backend/.sqlx/query-2f42460fdd8aa125c8fd46b3cd02e47f57de0f073d3ce3bc7d21a7e404a83b5c.json +++ b/backend/.sqlx/query-2f42460fdd8aa125c8fd46b3cd02e47f57de0f073d3ce3bc7d21a7e404a83b5c.json @@ -42,7 +42,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json new file mode 100644 index 0000000000..70ae06505c --- /dev/null +++ b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM usr WHERE username = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b" +} diff --git a/backend/.sqlx/query-620ddf29c5e867079df4c2aa6e80bccb19beeb9ddfa308ca97f254cd5ba8157e.json b/backend/.sqlx/query-620ddf29c5e867079df4c2aa6e80bccb19beeb9ddfa308ca97f254cd5ba8157e.json index 89583ff5ef..4598ac08df 100644 --- a/backend/.sqlx/query-620ddf29c5e867079df4c2aa6e80bccb19beeb9ddfa308ca97f254cd5ba8157e.json +++ b/backend/.sqlx/query-620ddf29c5e867079df4c2aa6e80bccb19beeb9ddfa308ca97f254cd5ba8157e.json @@ -62,7 +62,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json b/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json index 75b8108281..1fa370e682 100644 --- a/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json +++ b/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-6b313cc9a57ae3c943bda4a3213f7f6231a44b6ef5a52754074d136007f4f72a.json b/backend/.sqlx/query-6b313cc9a57ae3c943bda4a3213f7f6231a44b6ef5a52754074d136007f4f72a.json index 7d9d04bc37..72c175aff7 100644 --- a/backend/.sqlx/query-6b313cc9a57ae3c943bda4a3213f7f6231a44b6ef5a52754074d136007f4f72a.json +++ b/backend/.sqlx/query-6b313cc9a57ae3c943bda4a3213f7f6231a44b6ef5a52754074d136007f4f72a.json @@ -37,7 +37,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-9d3556319411a27a875bf6cf0e5eda837cc63e4d8be912c0b5bfeea4a0c8db2e.json b/backend/.sqlx/query-9d3556319411a27a875bf6cf0e5eda837cc63e4d8be912c0b5bfeea4a0c8db2e.json index 983c1586f0..f296c4afc6 100644 --- a/backend/.sqlx/query-9d3556319411a27a875bf6cf0e5eda837cc63e4d8be912c0b5bfeea4a0c8db2e.json +++ b/backend/.sqlx/query-9d3556319411a27a875bf6cf0e5eda837cc63e4d8be912c0b5bfeea4a0c8db2e.json @@ -42,7 +42,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-b69891c25dd029b1a54e97ace292433e1485324ff7dc802fe75d21c8c6db1d42.json b/backend/.sqlx/query-b69891c25dd029b1a54e97ace292433e1485324ff7dc802fe75d21c8c6db1d42.json index 86a1402ad0..f95d9d95b2 100644 --- a/backend/.sqlx/query-b69891c25dd029b1a54e97ace292433e1485324ff7dc802fe75d21c8c6db1d42.json +++ b/backend/.sqlx/query-b69891c25dd029b1a54e97ace292433e1485324ff7dc802fe75d21c8c6db1d42.json @@ -42,7 +42,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-ca5f42cb0e368d0817461600152aabefe718829fb6eae2f681c4c8328acc94b2.json b/backend/.sqlx/query-ca5f42cb0e368d0817461600152aabefe718829fb6eae2f681c4c8328acc94b2.json new file mode 100644 index 0000000000..eb5c3a8bf0 --- /dev/null +++ b/backend/.sqlx/query-ca5f42cb0e368d0817461600152aabefe718829fb6eae2f681c4c8328acc94b2.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members \n FROM usr u\n JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id\n RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_\n WHERE g_.workspace_id = $1 AND g_.name != 'all'\n GROUP BY g_.workspace_id, name, summary, extra_perms", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "summary", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "members", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + null + ] + }, + "hash": "ca5f42cb0e368d0817461600152aabefe718829fb6eae2f681c4c8328acc94b2" +} diff --git a/backend/.sqlx/query-ef132ac8d79579b08d7359789b6f22991f51e1c945efc2924df6253d62b83bba.json b/backend/.sqlx/query-ef132ac8d79579b08d7359789b6f22991f51e1c945efc2924df6253d62b83bba.json index 9ea1ab28f3..73af3f369c 100644 --- a/backend/.sqlx/query-ef132ac8d79579b08d7359789b6f22991f51e1c945efc2924df6253d62b83bba.json +++ b/backend/.sqlx/query-ef132ac8d79579b08d7359789b6f22991f51e1c945efc2924df6253d62b83bba.json @@ -42,7 +42,6 @@ "bash", "postgresql", "nativets", - "Nativets", "bun", "mysql", "bigquery", diff --git a/backend/.sqlx/query-f96dd1dd944506c07ad58d178e9b6a09259fedca329d790c34e58703bf26f34c.json b/backend/.sqlx/query-f96dd1dd944506c07ad58d178e9b6a09259fedca329d790c34e58703bf26f34c.json new file mode 100644 index 0000000000..81c15c15cf --- /dev/null +++ b/backend/.sqlx/query-f96dd1dd944506c07ad58d178e9b6a09259fedca329d790c34e58703bf26f34c.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM usr\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "f96dd1dd944506c07ad58d178e9b6a09259fedca329d790c34e58703bf26f34c" +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 02f3292d3a..c5cf27a50a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -180,29 +180,27 @@ paths: schema: type: string - /w/{workspace}/users/add: - post: - summary: create user (require admin privilege) - operationId: createUser + /w/{workspace}/users/{username}: + get: + summary: get user (require admin privilege) + operationId: getUser tags: - user - admin parameters: - $ref: "#/components/parameters/WorkspaceId" - requestBody: - description: new user - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/NewUser" + - name: username + in: path + required: true + schema: + type: string responses: - "201": + "200": description: user created content: - text/plain: + application/json: schema: - type: string + $ref: "#/components/schemas/User" /w/{workspace}/users/update/{username}: post: @@ -8447,20 +8445,6 @@ components: - email - password - NewUser: - type: object - properties: - email: - type: string - username: - type: string - is_admin: - type: boolean - required: - - email - - username - - is_admin - EditWorkspaceUser: type: object properties: diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index ba9dd8b813..57553b496b 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -60,6 +60,7 @@ pub fn workspaced_service() -> Router { .route("/list_usage", get(list_user_usage)) .route("/list_usernames", get(list_usernames)) .route("/exists", post(exists_username)) + .route("/get/:user", get(get_workspace_user)) .route("/update/:user", post(update_workspace_user)) .route("/delete/:user", delete(delete_workspace_user)) .route("/is_owner/*path", get(is_owner_of_path)) @@ -1457,6 +1458,28 @@ async fn leave_instance( Ok(format!("Left instance",)) } + +async fn get_workspace_user( + ApiAuthed { username, is_admin, .. }: ApiAuthed, + Extension(db): Extension, + Path((w_id, username_to_update)): Path<(String, String)>, +) -> Result> { + require_admin(is_admin, &username)?; + + let user = sqlx::query_as!( + User, + "SELECT * FROM usr WHERE username = $1 AND workspace_id = $2", + &username_to_update, + &w_id + ) + .fetch_optional(&db) + .await?; + + let user = not_found_if_none(user, "User", username_to_update)?; + + Ok(Json(user)) +} + async fn update_workspace_user( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 0a7b74a6d9..15c5826885 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -6,6 +6,8 @@ * LICENSE-AGPL for a copy of the license. */ +use std::collections::HashMap; + use crate::db::ApiAuthed; use crate::BASE_URL; use crate::{ @@ -28,6 +30,7 @@ use axum::{ }; use chrono::Utc; +use itertools::Itertools; use regex::Regex; use uuid::Uuid; @@ -1744,6 +1747,22 @@ async fn add_user( ))); } + let already_exists_email = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2)", + &w_id, + username, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + if already_exists_email { + return Err(Error::BadRequest(format!( + "user with email {} already exists in workspace {}", + email, w_id + ))); + } + sqlx::query!( "INSERT INTO usr (workspace_id, email, username, is_admin, operator) @@ -1767,6 +1786,17 @@ async fn add_user( .execute(&mut *tx) .await?; + audit_log( + &mut *tx, + &nu.username, + "users.add_to_workspace", + ActionKind::Create, + &w_id, + Some(&email), + None, + ) + .await?; + tx.commit().await?; send_email_if_possible( @@ -1933,11 +1963,17 @@ struct ArchiveQueryParams { skip_variables: Option, skip_resources: Option, include_schedules: Option, + include_users: Option, + include_groups: Option, default_ts: Option, } #[inline] -pub fn to_string_without_metadata(value: &T, preserve_extra_perms: bool) -> Result +pub fn to_string_without_metadata( + value: &T, + preserve_extra_perms: bool, + ignore_keys: Option>, +) -> Result where T: ?Sized + Serialize, { @@ -1945,23 +1981,29 @@ where value .as_object_mut() .map(|obj| { - for key in [ - "workspace_id", - "path", - "name", - "versions", - "id", - "created_at", - "updated_at", - "created_by", - "updated_by", - "edited_at", - "edited_by", - "archived", - "has_draft", - "draft_only", - "error", - ] { + let keys = [ + vec![ + "workspace_id", + "path", + "name", + "versions", + "id", + "created_at", + "updated_at", + "created_by", + "updated_by", + "edited_at", + "edited_by", + "archived", + "has_draft", + "draft_only", + "error", + ], + ignore_keys.unwrap_or(vec![]), + ] + .concat(); + + for key in keys { if obj.contains_key(key) { obj.remove(key); } @@ -1981,6 +2023,22 @@ where .ok_or_else(|| Error::BadRequest("Impossible to serialize value".to_string())) } +#[derive(Serialize)] +struct SimplifiedUser { + username: String, + role: String, + disabled: bool, + email: String, +} + +#[derive(Serialize)] +struct SimplifiedGroup { + name: String, + summary: Option, + members: Vec, + admins: Vec, +} + async fn tarball_workspace( authed: ApiAuthed, Extension(user_db): Extension, @@ -1994,6 +2052,8 @@ async fn tarball_workspace( skip_secrets, skip_variables, include_schedules, + include_users, + include_groups, default_ts, }): Query, ) -> Result<([(headers::HeaderName, String); 2], impl IntoResponse)> { @@ -2024,7 +2084,7 @@ async fn tarball_workspace( for folder in folders { archive .write_to_archive( - &to_string_without_metadata(&folder, true).unwrap(), + &to_string_without_metadata(&folder, true, None).unwrap(), &format!("f/{}/folder.meta.json", folder.name), ) .await?; @@ -2108,7 +2168,7 @@ async fn tarball_workspace( .await?; for resource in resources { - let resource_str = &to_string_without_metadata(&resource, false).unwrap(); + let resource_str = &to_string_without_metadata(&resource, false, None).unwrap(); archive .write_to_archive(&resource_str, &format!("{}.resource.json", resource.path)) .await?; @@ -2125,7 +2185,7 @@ async fn tarball_workspace( .await?; for resource_type in resource_types { - let resource_str = &to_string_without_metadata(&resource_type, false).unwrap(); + let resource_str = &to_string_without_metadata(&resource_type, false, None).unwrap(); archive .write_to_archive( &resource_str, @@ -2144,7 +2204,7 @@ async fn tarball_workspace( .await?; for flow in flows { - let flow_str = &to_string_without_metadata(&flow, false).unwrap(); + let flow_str = &to_string_without_metadata(&flow, false, None).unwrap(); archive .write_to_archive(&flow_str, &format!("{}.flow.json", flow.path)) .await?; @@ -2171,7 +2231,7 @@ async fn tarball_workspace( { var.value = Some(decrypt(&mc, var.value.unwrap())?); } - let var_str = &to_string_without_metadata(&var, false).unwrap(); + let var_str = &to_string_without_metadata(&var, false, None).unwrap(); archive .write_to_archive(&var_str, &format!("{}.variable.json", var.path)) .await?; @@ -2191,7 +2251,7 @@ async fn tarball_workspace( .await?; for app in apps { - let app_str = &to_string_without_metadata(&app, false).unwrap(); + let app_str = &to_string_without_metadata(&app, false, None).unwrap(); archive .write_to_archive(&app_str, &format!("{}.app.json", app.path)) .await?; @@ -2209,13 +2269,107 @@ async fn tarball_workspace( .await?; for schedule in schedules { - let app_str = &to_string_without_metadata(&schedule, false).unwrap(); + let app_str = &to_string_without_metadata(&schedule, false, None).unwrap(); archive .write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path)) .await?; } } + if include_users.unwrap_or(false) { + let users = sqlx::query!( + "SELECT * FROM usr + WHERE workspace_id = $1", + &w_id + ) + .fetch_all(&mut *tx) + .await?; + + for user in users { + let user = SimplifiedUser { + username: user.username, + role: if user.is_admin { + "admin".to_string() + } else if user.operator { + "operator".to_string() + } else { + "developer".to_string() + }, + disabled: user.disabled, + email: user.email, + }; + let user_str = &to_string_without_metadata( + &user, + false, + Some(vec!["is_admin", "operator", "email"]), + ) + .unwrap(); + archive + .write_to_archive(&user_str, &format!("users/{}.user.json", user.email)) + .await?; + } + } + + if include_groups.unwrap_or(false) { + let groups = sqlx::query!( + r#"SELECT g_.workspace_id, name, summary, extra_perms, array_agg(u2g.usr) filter (where u2g.usr is not null) as members + FROM usr u + JOIN usr_to_group u2g ON u2g.usr = u.username AND u2g.workspace_id = u.workspace_id + RIGHT JOIN group_ g_ ON g_.workspace_id = u.workspace_id AND g_.name = u2g.group_ + WHERE g_.workspace_id = $1 AND g_.name != 'all' + GROUP BY g_.workspace_id, name, summary, extra_perms"#, + &w_id + ) + .fetch_all(&mut *tx) + .await?; + + for group in groups { + let extra_perms: HashMap = serde_json::from_value(group.extra_perms) + .map_err(|e| { + Error::InternalErr(format!( + "Error parsing extra_perms for group {}: {}", + group.name, e + )) + })?; + tracing::info!("{:?}", extra_perms); + let members = group.members.unwrap_or(vec![]); + let admins: Vec = extra_perms + .iter() + .filter_map(|(k, v)| { + // only consider extra_perms that concern actual members of the group + if members.contains(&k[2..].to_string()) && *v { + Some(k.clone()) + } else { + None + } + }) + .sorted() + .collect(); + let group = SimplifiedGroup { + name: group.name, + summary: group.summary, + members: members + .iter() + .filter_map(|x| { + // remove members that are also admins as they are already in the admins list + let full_name = format!("u/{}", x); + if !admins.contains(&full_name) { + Some(full_name) + } else { + None + } + }) + .collect(), + admins, + }; + + let group_str = &to_string_without_metadata(&group, true, None).unwrap(); + archive + .write_to_archive(&group_str, &format!("groups/{}.group.json", group.name)) + .await?; + } + } + archive.finish().await?; let file = tokio::fs::File::open(&file_path).await?; diff --git a/cli/conf.ts b/cli/conf.ts index fbcb7cd031..c85e065667 100644 --- a/cli/conf.ts +++ b/cli/conf.ts @@ -12,6 +12,8 @@ export interface SyncOptions { skipResources?: boolean; skipSecrets?: boolean; includeSchedules?: boolean; + includeUsers?: boolean; + includeGroups?: boolean; message?: string; includes?: string[]; extraIncludes?: string[]; diff --git a/cli/deps.ts b/cli/deps.ts index b58f7d32a3..a410f09b5b 100644 --- a/cli/deps.ts +++ b/cli/deps.ts @@ -1,6 +1,6 @@ // windmill -export { setClient } from "https://deno.land/x/windmill@v1.226.1/mod.ts"; -export * from "https://deno.land/x/windmill@v1.226.1/windmill-api/index.ts"; +export { setClient } from "https://deno.land/x/windmill@v1.283.0/mod.ts"; +export * from "https://deno.land/x/windmill@v1.283.0/windmill-api/index.ts"; export { SEP } from "https://deno.land/std@0.201.0/path/separator.ts"; // cliffy export { Command } from "https://deno.land/x/cliffy@v1.0.0-rc.3/command/mod.ts"; diff --git a/cli/pull.ts b/cli/pull.ts index b23189a71e..091e06989b 100644 --- a/cli/pull.ts +++ b/cli/pull.ts @@ -11,6 +11,8 @@ export async function downloadZip( skipResources?: boolean, skipSecrets?: boolean, includeSchedules?: boolean, + includeUsers?: boolean, + includeGroups?: boolean, defaultTs?: "bun" | "deno" ): Promise { const requestHeaders: HeadersInit = new Headers(); @@ -34,6 +36,8 @@ export async function downloadZip( skipResources ?? false }&skip_secrets=${skipSecrets ?? false}&include_schedules=${ includeSchedules ?? false + }&include_users=${includeUsers ?? false}&include_groups=${ + includeGroups ?? false }&default_ts=${defaultTs ?? "deno"}`, { headers: requestHeaders, diff --git a/cli/sync.ts b/cli/sync.ts index 2e97d19728..a890387067 100644 --- a/cli/sync.ts +++ b/cli/sync.ts @@ -321,6 +321,8 @@ export async function elementsToMap( if (!json && path.endsWith(".json")) continue; const ext = json ? ".json" : ".yaml"; if (!skips.includeSchedules && path.endsWith(".schedule" + ext)) continue; + if (!skips.includeUsers && path.endsWith(".user" + ext)) continue; + if (!skips.includeGroups && path.endsWith(".group" + ext)) continue; if (skips.skipResources && path.endsWith(".resource" + ext)) continue; if (skips.skipVariables && path.endsWith(".variable" + ext)) continue; @@ -359,6 +361,8 @@ interface Skips { skipResources?: boolean | undefined; skipSecrets?: boolean | undefined; includeSchedules?: boolean | undefined; + includeUsers?: boolean | undefined; + includeGroups?: boolean | undefined; } async function compareDynFSElement( @@ -456,8 +460,12 @@ function getOrderFromPath(p: string) { return 6; } else if (typ == "variable") { return 7; - } else { + } else if (typ == "user") { return 8; + } else if (typ == "group") { + return 9; + } else { + return 10; } } @@ -469,7 +477,9 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => { return ( !p.startsWith("u" + SEP) && !p.startsWith("f" + SEP) && - !p.startsWith("g" + SEP) + !p.startsWith("g" + SEP) && + !p.startsWith("users" + SEP) && + !p.startsWith("groups" + SEP) ); } @@ -481,7 +491,9 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => { return ( !p.startsWith("u" + SEP) && !p.startsWith("f" + SEP) && - !p.startsWith("g" + SEP) + !p.startsWith("g" + SEP) && + !p.startsWith("users" + SEP) && + !p.startsWith("groups" + SEP) ); } } catch { @@ -490,7 +502,15 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => { }; export const isWhitelisted = (p: string) => { - return p == "." + SEP || p == "" || p == "u" || p == "f" || p == "g"; + return ( + p == "." + SEP || + p == "" || + p == "u" || + p == "f" || + p == "g" || + p == "users" || + p == "groups" + ); }; export async function ignoreF(wmillconf: { @@ -576,6 +596,8 @@ async function pull(opts: GlobalOptions & SyncOptions) { opts.skipResources, opts.skipSecrets, opts.includeSchedules, + opts.includeUsers, + opts.includeGroups, opts.defaultTs ))!, !opts.json @@ -787,6 +809,8 @@ async function push(opts: GlobalOptions & SyncOptions) { opts.skipResources, opts.skipSecrets, opts.includeSchedules, + opts.includeUsers, + opts.includeGroups, opts.defaultTs ))!, !opts.json @@ -1030,6 +1054,8 @@ const command = new Command() .option("--skip-secrets", "Skip syncing only secrets variables") .option("--skip-resources", "Skip syncing resources") .option("--include-schedules", "Include syncing schedules") + .option("--include-users", "Include syncing users") + .option("--include-groups", "Include syncing groups") .option( "-i --includes ", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)" @@ -1063,7 +1089,9 @@ const command = new Command() .option("--skip-variables", "Skip syncing variables (including secrets)") .option("--skip-secrets", "Skip syncing only secrets variables") .option("--skip-resources", "Skip syncing resources") - .option("--include-schedules", "Include syncing schedules") + .option("--include-schedules", "Include syncing schedules") + .option("--include-users", "Include syncing users") + .option("--include-groups", "Include syncing groups") .option( "-i --includes ", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)" diff --git a/cli/types.ts b/cli/types.ts index b40b34d001..972b918887 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -12,6 +12,8 @@ import { yamlOptions } from "./sync.ts"; import { showDiffs } from "./main.ts"; import { deepEqual } from "./utils.ts"; import { pushSchedule } from "./schedule.ts"; +import { pushWorkspaceUser } from "./user.ts"; +import { pushGroup } from "./user.ts"; export interface DifferenceCreate { type: "CREATE"; @@ -122,6 +124,10 @@ export async function pushObj( await pushResourceType(workspace, p, befObj, newObj); } else if (typeEnding === "schedule") { await pushSchedule(workspace, p, befObj, newObj); + } else if (typeEnding === "user") { + await pushWorkspaceUser(workspace, p, befObj, newObj); + } else if (typeEnding === "group") { + await pushGroup(workspace, p, befObj, newObj); } else { throw new Error( `The item ${p} has an unrecognized type ending ${typeEnding}` @@ -155,7 +161,9 @@ export function getTypeStrFromPath( | "resource-type" | "folder" | "app" - | "schedule" { + | "schedule" + | "user" + | "group" { if (p.includes(".flow" + path.sep)) { return "flow"; } @@ -183,7 +191,9 @@ export function getTypeStrFromPath( typeEnding === "resource" || typeEnding === "resource-type" || typeEnding === "app" || - typeEnding === "schedule" + typeEnding === "schedule" || + typeEnding === "user" || + typeEnding === "group" ) { return typeEnding; } else { @@ -200,3 +210,10 @@ export function removeType(str: string, type: string) { } return str.slice(0, str.length - type.length - 6); } + +export function removePathPrefix(str: string, prefix: string) { + if (!str.startsWith(prefix + "/")) { + throw new Error(str + " does not start with " + prefix); + } + return str.slice(prefix.length + 1); +} diff --git a/cli/user.ts b/cli/user.ts index f5c0d0278d..5050c1fa83 100644 --- a/cli/user.ts +++ b/cli/user.ts @@ -1,6 +1,11 @@ // deno-lint-ignore-file no-explicit-any import { requireLogin } from "./context.ts"; -import { GlobalOptions } from "./types.ts"; +import { + GlobalOptions, + isSuperset, + removeType, + removePathPrefix, +} from "./types.ts"; import { colors, Command, @@ -9,6 +14,9 @@ import { passwordGenerator, Table, UserService, + GroupService, + WorkspaceService, + GranularAclService, } from "./deps.ts"; async function list(opts: GlobalOptions) { @@ -93,6 +101,269 @@ async function createToken( log.info("Token: " + (await UserService.createToken({ requestBody: {} }))); } +interface SimplifiedUser { + role: string; + username: string; + disabled: boolean; +} + +export async function pushWorkspaceUser( + workspace: string, + path: string, + user: SimplifiedUser | undefined, + localUser: SimplifiedUser +): Promise { + const email = removePathPrefix(removeType(path, "user"), "users"); + + log.debug(`Processing local user ${email}`); + + if (!["operator", "developer", "admin"].includes(localUser.role)) { + throw new Error(`Invalid role for user ${email}: ${localUser.role}`); + } + + try { + const remoteUser = await UserService.getUser({ + workspace, + username: localUser.username, + }); + user = { + role: remoteUser.is_admin + ? "admin" + : remoteUser.operator + ? "operator" + : "developer", + username: remoteUser.username, + disabled: remoteUser.disabled, + }; + log.debug(`User ${email} exists on remote`); + } catch { + log.debug(`User ${email} does not exist on remote`); + //ignore + } + + if (user) { + if (isSuperset(localUser, user)) { + log.debug(`User ${email} is up to date`); + return; + } + log.debug(`User ${email} is not up-to-date, updating...`); + try { + await UserService.updateUser({ + workspace: workspace, + username: localUser.username, + requestBody: { + is_admin: localUser.role === "admin", + operator: localUser.role === "operator", + disabled: localUser.disabled, + }, + }); + } catch (e) { + console.error(e.body); + throw e; + } + } else { + console.log(colors.bold.yellow("Creating new user: " + email)); + try { + await WorkspaceService.addUser({ + workspace: workspace, + requestBody: { + email: email, + is_admin: localUser.role === "admin", + operator: localUser.role === "operator", + username: localUser.username, + }, + }); + } catch (e) { + console.error(e.body); + throw e; + } + } +} + +interface SimplifiedGroup { + summary: string | undefined; + admins: string[]; + members: string[]; +} + +export async function pushGroup( + workspace: string, + path: string, + group: SimplifiedGroup | undefined, + localGroup: SimplifiedGroup +): Promise { + const name = removePathPrefix(removeType(path, "group"), "groups"); + + log.debug(`Processing local group ${name}`); + + try { + const remoteGroup = await GroupService.getGroup({ + workspace, + name, + }); + + // only consider extra_perms that concern actual members of the group + const admins = Object.entries(remoteGroup.extra_perms ?? {}) + .filter(([k, v]) => v && remoteGroup.members?.includes(k.slice(2))) + .map(([k, _]) => k) + .sort(); + group = { + summary: remoteGroup.summary, + admins, + // remove members that are also admins as they are already in the admins list + members: (remoteGroup.members ?? []) + .map((m) => "u/" + m) + .filter((m) => !admins.includes(m)), + }; + log.debug(`Group ${name} exists on remote`); + } catch { + log.debug(`Group ${name} does not exist on remote`); + //ignore + } + + if (group) { + if (isSuperset(localGroup, group)) { + log.debug(`Group ${name} is up to date`); + return; + } + log.debug(`Group ${name} is not up-to-date, updating...`); + try { + await GroupService.updateGroup({ + workspace: workspace, + name, + requestBody: { + summary: localGroup.summary, + }, + }); + } catch (e) { + console.error(e.body); + throw e; + } + + for (const member of [...localGroup.members, ...localGroup.admins]) { + try { + if ([...group.members, ...group.admins].includes(member)) { + log.debug(`${member} is already in group ${name}`); + } else { + log.debug(`Adding ${member} to group ${name}`); + await GroupService.addUserToGroup({ + workspace: workspace, + name, + requestBody: { + username: member.slice(2), + }, + }); + } + if ( + localGroup.admins.includes(member) && + !group.admins.includes(member) + ) { + log.debug(`Setting role of ${member} as admin in group ${name}`); + await GranularAclService.addGranularAcls({ + workspace: workspace, + kind: "group_", + path: name, + requestBody: { + owner: member, + write: true, + }, + }); + } + + if ( + localGroup.members.includes(member) && + !group.members.includes(member) + ) { + log.debug(`Setting role of ${member} as member in group ${name}`); + await GranularAclService.addGranularAcls({ + workspace: workspace, + kind: "group_", + path: name, + requestBody: { + owner: member, + write: false, + }, + }); + } + } catch (e) { + console.error(e.body); + throw e; + } + } + + for (const member of [...group.members, ...group.admins]) { + if (![...localGroup.members, ...localGroup.admins].includes(member)) { + log.debug( + `Removing ${member} and any associated role from group ${name}` + ); + try { + await GroupService.removeUserToGroup({ + workspace: workspace, + name, + requestBody: { + username: member.slice(2), + }, + }); + + await GranularAclService.removeGranularAcls({ + workspace: workspace, + kind: "group_", + path: name, + requestBody: { + owner: member, + }, + }); + } catch (e) { + console.error(e.body); + throw e; + } + } + } + } else { + console.log(colors.bold.yellow("Creating new user: " + name)); + try { + await GroupService.createGroup({ + workspace: workspace, + requestBody: { + name, + summary: localGroup.summary, + }, + }); + + for (const member of [...localGroup.members, ...localGroup.admins]) { + log.debug(`Adding user ${member} to group ${name}`); + try { + await GroupService.addUserToGroup({ + workspace: workspace, + name, + requestBody: { + username: member.slice(2), + }, + }); + if (localGroup.admins.includes(member)) { + log.debug(`Setting role of ${member} as admin in group ${name}`); + await GranularAclService.addGranularAcls({ + workspace: workspace, + kind: "group_", + path: name, + requestBody: { + owner: member, + write: true, + }, + }); + } + } catch (e) { + console.error(e.body); + throw e; + } + } + } catch (e) { + console.error(e.body); + throw e; + } + } +} + const command = new Command() .description("user related commands") .action(list as any)