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:
HugoCasa
2024-03-01 19:25:14 +01:00
committed by GitHub
parent c997d0670e
commit 8812dfd428
22 changed files with 722 additions and 73 deletions
@@ -48,7 +48,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -69,7 +69,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -28,7 +28,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -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"
}
@@ -62,7 +62,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -37,7 +37,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -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"
}
@@ -42,7 +42,6 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",
@@ -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"
}
+12 -28
View File
@@ -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:
+23
View File
@@ -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>,
+179 -25
View File
@@ -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?;
+2
View File
@@ -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
View File
@@ -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";
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)