mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 00:01:37 +00:00
feat: sync users and groups (#3328)
* feat: wmil sync for users and groups * fix: sort admins * fix: sqlx build * chore: update to latest deno client
This commit is contained in:
-1
@@ -48,7 +48,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
-1
@@ -69,7 +69,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
-1
@@ -28,7 +28,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
-1
@@ -42,7 +42,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
+65
@@ -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"
|
||||
}
|
||||
-1
@@ -62,7 +62,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"name": "bool",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
|
||||
-1
@@ -37,7 +37,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
-1
@@ -42,7 +42,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
-1
@@ -42,7 +42,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
+46
@@ -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"
|
||||
}
|
||||
-1
@@ -42,7 +42,6 @@
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"Nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
|
||||
+64
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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<DB>,
|
||||
Path((w_id, username_to_update)): Path<(String, String)>,
|
||||
) -> Result<Json<User>> {
|
||||
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<DB>,
|
||||
|
||||
@@ -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<bool>,
|
||||
skip_resources: Option<bool>,
|
||||
include_schedules: Option<bool>,
|
||||
include_users: Option<bool>,
|
||||
include_groups: Option<bool>,
|
||||
default_ts: Option<String>,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn to_string_without_metadata<T>(value: &T, preserve_extra_perms: bool) -> Result<String>
|
||||
pub fn to_string_without_metadata<T>(
|
||||
value: &T,
|
||||
preserve_extra_perms: bool,
|
||||
ignore_keys: Option<Vec<&str>>,
|
||||
) -> Result<String>
|
||||
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<String>,
|
||||
members: Vec<String>,
|
||||
admins: Vec<String>,
|
||||
}
|
||||
|
||||
async fn tarball_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -1994,6 +2052,8 @@ async fn tarball_workspace(
|
||||
skip_secrets,
|
||||
skip_variables,
|
||||
include_schedules,
|
||||
include_users,
|
||||
include_groups,
|
||||
default_ts,
|
||||
}): Query<ArchiveQueryParams>,
|
||||
) -> 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<String, bool> = 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<String> = 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?;
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface SyncOptions {
|
||||
skipResources?: boolean;
|
||||
skipSecrets?: boolean;
|
||||
includeSchedules?: boolean;
|
||||
includeUsers?: boolean;
|
||||
includeGroups?: boolean;
|
||||
message?: string;
|
||||
includes?: string[];
|
||||
extraIncludes?: string[];
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
@@ -11,6 +11,8 @@ export async function downloadZip(
|
||||
skipResources?: boolean,
|
||||
skipSecrets?: boolean,
|
||||
includeSchedules?: boolean,
|
||||
includeUsers?: boolean,
|
||||
includeGroups?: boolean,
|
||||
defaultTs?: "bun" | "deno"
|
||||
): Promise<JSZip | undefined> {
|
||||
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,
|
||||
|
||||
+33
-5
@@ -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 <patterns:file[]>",
|
||||
"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 <patterns:file[]>",
|
||||
"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)"
|
||||
|
||||
+19
-2
@@ -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);
|
||||
}
|
||||
|
||||
+272
-1
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user