Browse Source

Merge pull request #238 from zilliztech/user_roles

Support assign privilege
ryjiang 1 year ago
parent
commit
1ce967ed7b

+ 26 - 2
client/src/components/customDialog/DeleteDialogTemplate.tsx

@@ -5,6 +5,8 @@ import {
   TextField,
   TextField,
   Theme,
   Theme,
   Typography,
   Typography,
+  Checkbox,
+  FormControlLabel,
 } from '@material-ui/core';
 } from '@material-ui/core';
 import { ChangeEvent, FC, useContext, useState } from 'react';
 import { ChangeEvent, FC, useContext, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -36,16 +38,19 @@ const useStyles = makeStyles((theme: Theme) => ({
   cancelBtn: {
   cancelBtn: {
     color: theme.palette.attuGrey.dark,
     color: theme.palette.attuGrey.dark,
   },
   },
+  checkBox: {},
 }));
 }));
 
 
 const DeleteTemplate: FC<DeleteDialogContentType> = props => {
 const DeleteTemplate: FC<DeleteDialogContentType> = props => {
-  const { title, text, label, handleDelete, handleCancel } = props;
+  const { title, text, label, handleDelete, handleCancel, forceDelLabel } =
+    props;
   const { handleCloseDialog } = useContext(rootContext);
   const { handleCloseDialog } = useContext(rootContext);
   const classes = useStyles();
   const classes = useStyles();
   const { t: dialogTrans } = useTranslation('dialog');
   const { t: dialogTrans } = useTranslation('dialog');
   const { t: btnTrans } = useTranslation('btn');
   const { t: btnTrans } = useTranslation('btn');
 
 
   const [value, setValue] = useState<string>('');
   const [value, setValue] = useState<string>('');
+  const [force, setForce] = useState<boolean>(false);
   const [deleteReady, setDeleteReady] = useState<boolean>(false);
   const [deleteReady, setDeleteReady] = useState<boolean>(false);
 
 
   const onCancelClick = () => {
   const onCancelClick = () => {
@@ -54,7 +59,7 @@ const DeleteTemplate: FC<DeleteDialogContentType> = props => {
   };
   };
 
 
   const onDeleteClick = (event: React.FormEvent<HTMLFormElement>) => {
   const onDeleteClick = (event: React.FormEvent<HTMLFormElement>) => {
-    handleDelete();
+    handleDelete(force);
     event.preventDefault();
     event.preventDefault();
   };
   };
 
 
@@ -100,6 +105,25 @@ const DeleteTemplate: FC<DeleteDialogContentType> = props => {
             variant="filled"
             variant="filled"
             fullWidth={true}
             fullWidth={true}
           />
           />
+          {forceDelLabel ? (
+            <FormControlLabel
+              control={
+                <Checkbox
+                  onChange={(
+                    e: React.ChangeEvent<HTMLInputElement>,
+                    checked: boolean
+                  ) => {
+                    setForce(checked);
+                  }}
+                />
+              }
+              key={'force'}
+              label={forceDelLabel}
+              value={true}
+              checked={force}
+              className={classes.checkBox}
+            />
+          ) : null}
         </DialogContent>
         </DialogContent>
 
 
         <DialogActions className={classes.btnWrapper}>
         <DialogActions className={classes.btnWrapper}>

+ 2 - 1
client/src/components/customDialog/Types.ts

@@ -18,7 +18,8 @@ export type DeleteDialogContentType = {
   text: string;
   text: string;
   label: string;
   label: string;
   handleCancel?: () => void;
   handleCancel?: () => void;
-  handleDelete: () => void;
+  handleDelete: (force?: boolean) => void;
+  forceDelLabel?: string;
 };
 };
 
 
 export type DialogContainerProps = {
 export type DialogContainerProps = {

+ 3 - 2
client/src/http/BaseModel.ts

@@ -70,8 +70,9 @@ export default class BaseModel {
   }
   }
 
 
   static async delete(options: updateParamsType) {
   static async delete(options: updateParamsType) {
-    const { path } = options;
-    const res = await http.delete(path);
+    const { path, data } = options;
+
+    const res = await http.delete(path, { data: data });
 
 
     return res.data;
     return res.data;
   }
   }

+ 40 - 17
client/src/http/User.ts

@@ -17,50 +17,73 @@ export class UserHttp extends BaseModel {
     Object.assign(this, props);
     Object.assign(this, props);
   }
   }
 
 
-  static USER_URL = `/users`;
+  static USERS_URL = `/users`;
+  static ROLES_URL = `/users/roles`;
 
 
+  // get user data
   static getUsers() {
   static getUsers() {
-    return super.search({ path: this.USER_URL, params: {} });
+    return super.search({ path: this.USERS_URL, params: {} });
   }
   }
 
 
+  // create user
   static createUser(data: CreateUserParams) {
   static createUser(data: CreateUserParams) {
-    return super.create({ path: this.USER_URL, data });
+    return super.create({ path: this.USERS_URL, data });
   }
   }
 
 
+  // update user (pass)
   static updateUser(data: UpdateUserParams) {
   static updateUser(data: UpdateUserParams) {
-    return super.update({ path: this.USER_URL, data });
+    return super.update({ path: this.USERS_URL, data });
   }
   }
 
 
+  // delete user
   static deleteUser(data: DeleteUserParams) {
   static deleteUser(data: DeleteUserParams) {
-    return super.delete({ path: `${this.USER_URL}/${data.username}` });
+    return super.delete({ path: `${this.USERS_URL}/${data.username}` });
   }
   }
 
 
-  static createRole(data: CreateRoleParams) {
-    return super.create({ path: `${this.USER_URL}/roles`, data });
+  // update user role
+  static updateUserRole(data: AssignRoleParams) {
+    return super.update({
+      path: `${this.USERS_URL}/${data.username}/role/update`,
+      data,
+    });
   }
   }
 
 
-  static getRoles() {
-    return super.search({ path: `${this.USER_URL}/roles`, params: {} });
+  // unassign user role
+  static unassignUserRole(data: UnassignRoleParams) {
+    return super.update({
+      path: `${this.USERS_URL}/${data.username}/role/unassign`,
+      data,
+    });
   }
   }
 
 
+  // create a role
+  static createRole(data: CreateRoleParams) {
+    return super.create({ path: `${this.ROLES_URL}`, data });
+  }
+
+  // delete a role
   static deleteRole(data: DeleteRoleParams) {
   static deleteRole(data: DeleteRoleParams) {
-    return super.delete({ path: `${this.USER_URL}/roles/${data.roleName}` });
+    return super.delete({ path: `${this.ROLES_URL}/${data.roleName}`, data });
   }
   }
 
 
-  static updateUserRole(data: AssignRoleParams) {
-    return super.update({
-      path: `${this.USER_URL}/${data.username}/role/update`,
-      data,
-    });
+  // get all roles
+  static getRoles() {
+    return super.search({ path: `${this.ROLES_URL}`, params: {} });
   }
   }
 
 
-  static unassignUserRole(data: UnassignRoleParams) {
+  // update role privileges
+  static updateRolePrivileges(data: CreateRoleParams) {
     return super.update({
     return super.update({
-      path: `${this.USER_URL}/${data.username}/role/unassign`,
+      path: `${this.ROLES_URL}/${data.roleName}/updatePrivileges`,
       data,
       data,
     });
     });
   }
   }
 
 
+  // get RBAC info
+  static getRBAC() {
+    return super.search({ path: `${this.USERS_URL}/rbac`, params: {} });
+  }
+
   get _names() {
   get _names() {
     return this.names;
     return this.names;
   }
   }

+ 14 - 0
client/src/i18n/en/user.ts

@@ -12,12 +12,26 @@ const userTrans = {
   isNotSame: 'Not same as new password',
   isNotSame: 'Not same as new password',
   deleteTip:
   deleteTip:
     'Please select at least one item to drop and the root user can not be dropped.',
     'Please select at least one item to drop and the root user can not be dropped.',
+
+  // role
   deleteEditRoleTip: 'root role is not editable.',
   deleteEditRoleTip: 'root role is not editable.',
+  disableEditRolePrivilegeTip: 'admin and public role are not editable.',
+
   role: 'Role',
   role: 'Role',
   editRole: 'Edit Role',
   editRole: 'Edit Role',
   roles: 'Roles',
   roles: 'Roles',
   createRoleTitle: 'Create Role',
   createRoleTitle: 'Create Role',
+  updateRolePrivilegeTitle: 'Update Role',
   updateRoleSuccess: 'User Role',
   updateRoleSuccess: 'User Role',
+  type: 'Type',
+
+  // Privileges
+  privileges: 'Privileges',
+  objectCollection: 'Collection',
+  objectGlobal: 'Global',
+  objectUser: 'User',
+
+  forceDelLabel: 'Force delete, revoke all privileges.',
 };
 };
 
 
 export default userTrans;
 export default userTrans;

+ 0 - 86
client/src/pages/user/CreateRole.tsx

@@ -1,86 +0,0 @@
-import { makeStyles, Theme } from '@material-ui/core';
-import { FC, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import DialogTemplate from '@/components/customDialog/DialogTemplate';
-import CustomInput from '@/components/customInput/CustomInput';
-import { ITextfieldConfig } from '@/components/customInput/Types';
-import { useFormValidation } from '@/hooks/Form';
-import { formatForm } from '@/utils/Form';
-import { CreateRoleProps, CreateRoleParams } from './Types';
-
-const useStyles = makeStyles((theme: Theme) => ({
-  input: {
-    margin: theme.spacing(3, 0, 0.5),
-  },
-}));
-
-const CreateRole: FC<CreateRoleProps> = ({ handleCreate, handleClose }) => {
-  const { t: commonTrans } = useTranslation();
-  const { t: userTrans } = useTranslation('user');
-  const { t: btnTrans } = useTranslation('btn');
-  const { t: warningTrans } = useTranslation('warning');
-  const attuTrans = commonTrans('attu');
-
-  const [form, setForm] = useState<CreateRoleParams>({
-    roleName: '',
-  });
-  const checkedForm = useMemo(() => {
-    return formatForm(form);
-  }, [form]);
-  const { validation, checkIsValid, disabled } = useFormValidation(checkedForm);
-
-  const classes = useStyles();
-
-  const handleInputChange = (key: 'roleName' | 'password', value: string) => {
-    setForm(v => ({ ...v, [key]: value }));
-  };
-
-  const createConfigs: ITextfieldConfig[] = [
-    {
-      label: userTrans('role'),
-      key: 'roleName',
-      onChange: (value: string) => handleInputChange('roleName', value),
-      variant: 'filled',
-      className: classes.input,
-      placeholder: userTrans('role'),
-      fullWidth: true,
-      validations: [
-        {
-          rule: 'require',
-          errorText: warningTrans('required', {
-            name: userTrans('role'),
-          }),
-        },
-      ],
-      defaultValue: form.roleName,
-    },
-  ];
-
-  const handleCreateRole = () => {
-    handleCreate(form);
-  };
-
-  return (
-    <DialogTemplate
-      title={userTrans('createRoleTitle')}
-      handleClose={handleClose}
-      confirmLabel={btnTrans('create')}
-      handleConfirm={handleCreateRole}
-      confirmDisabled={disabled}
-    >
-      <>
-        {createConfigs.map(v => (
-          <CustomInput
-            type="text"
-            textConfig={v}
-            checkValid={checkIsValid}
-            validInfo={validation}
-            key={v.label}
-          />
-        ))}
-      </>
-    </DialogTemplate>
-  );
-};
-
-export default CreateRole;

+ 1 - 1
client/src/pages/user/CreateUser.tsx

@@ -18,7 +18,7 @@ import { Option as RoleOption } from '@/components/customSelector/Types';
 
 
 const useStyles = makeStyles((theme: Theme) => ({
 const useStyles = makeStyles((theme: Theme) => ({
   input: {
   input: {
-    margin: theme.spacing(2, 0, 0.5),
+    margin: theme.spacing(1, 0, 0.5),
   },
   },
   dialogWrapper: {
   dialogWrapper: {
     maxWidth: theme.spacing(70),
     maxWidth: theme.spacing(70),

+ 79 - 0
client/src/pages/user/PrivilegeOptions.tsx

@@ -0,0 +1,79 @@
+import {
+  makeStyles,
+  Theme,
+  Typography,
+  Checkbox,
+  FormGroup,
+  FormControlLabel,
+} from '@material-ui/core';
+import { FC } from 'react';
+import { Privilege, PrivilegeOptionsProps } from './Types';
+
+const useStyles = makeStyles((theme: Theme) => ({
+  checkBox: {
+    width: theme.spacing(24),
+  },
+  formGrp: {
+    marginBottom: theme.spacing(2),
+  },
+  subTitle: {
+    marginBottom: theme.spacing(0.5),
+  },
+}));
+
+const PrivilegeOptions: FC<PrivilegeOptionsProps> = ({
+  options,
+  selection,
+  onChange,
+  title,
+  roleName,
+  object,
+  objectName = '*',
+}) => {
+  const classes = useStyles();
+
+  return (
+    <>
+      <Typography variant="h6" component="h6" className={classes.subTitle}>
+        {title}
+      </Typography>
+      <FormGroup row className={classes.formGrp}>
+        {options.map((r: string) => (
+          <FormControlLabel
+            control={
+              <Checkbox
+                onChange={(
+                  e: React.ChangeEvent<HTMLInputElement>,
+                  checked: boolean
+                ) => {
+                  let newSelection = [...selection];
+
+                  if (!checked) {
+                    newSelection = newSelection.filter(
+                      (n: Privilege) => n.privilegeName !== r
+                    );
+                  } else {
+                    newSelection.push({
+                      privilegeName: r,
+                      object: object,
+                      objectName: objectName,
+                      roleName: roleName,
+                    });
+                  }
+                  onChange(newSelection);
+                }}
+              />
+            }
+            key={r}
+            label={r}
+            value={r}
+            checked={selection.filter((s: Privilege) => s.privilegeName === r).length > 0}
+            className={classes.checkBox}
+          />
+        ))}
+      </FormGroup>
+    </>
+  );
+};
+
+export default PrivilegeOptions;

+ 76 - 13
client/src/pages/user/Roles.tsx

@@ -1,20 +1,23 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { makeStyles, Theme } from '@material-ui/core';
+import { useContext, useEffect, useState } from 'react';
+import { makeStyles, Theme, Chip } from '@material-ui/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UserHttp } from '@/http/User';
 import { UserHttp } from '@/http/User';
 import AttuGrid from '@/components/grid/Grid';
 import AttuGrid from '@/components/grid/Grid';
 import { ColDefinitionsType, ToolBarConfig } from '@/components/grid/Types';
 import { ColDefinitionsType, ToolBarConfig } from '@/components/grid/Types';
-import { CreateRoleParams, DeleteRoleParams, RoleData } from './Types';
+import { DeleteRoleParams, RoleData } from './Types';
 import DeleteTemplate from '@/components/customDialog/DeleteDialogTemplate';
 import DeleteTemplate from '@/components/customDialog/DeleteDialogTemplate';
 import { rootContext } from '@/context/Root';
 import { rootContext } from '@/context/Root';
 import { useNavigationHook } from '@/hooks/Navigation';
 import { useNavigationHook } from '@/hooks/Navigation';
 import { ALL_ROUTER_TYPES } from '@/router/Types';
 import { ALL_ROUTER_TYPES } from '@/router/Types';
-import CreateRole from './CreateRole';
+import UpdateRoleDialog from './UpdateRoleDialog';
 
 
 const useStyles = makeStyles((theme: Theme) => ({
 const useStyles = makeStyles((theme: Theme) => ({
   wrapper: {
   wrapper: {
     height: `calc(100vh - 160px)`,
     height: `calc(100vh - 160px)`,
   },
   },
+  chip: {
+    marginRight: theme.spacing(0.5),
+  },
 }));
 }));
 
 
 const Roles = () => {
 const Roles = () => {
@@ -32,21 +35,46 @@ const Roles = () => {
 
 
   const fetchRoles = async () => {
   const fetchRoles = async () => {
     const roles = await UserHttp.getRoles();
     const roles = await UserHttp.getRoles();
-
-    setRoles(roles.results.map((v: any) => ({ name: v.role.name })));
+    setSelectedRole([]);
+
+    setRoles(
+      roles.results.map((v: any) => ({
+        name: v.role.name,
+        privilegeContent: (
+          <>
+            {v.entities.map((e: any) => {
+              return (
+                <Chip
+                  className={classes.chip}
+                  size="small"
+                  label={e.grantor.privilege.name}
+                  variant="outlined"
+                />
+              );
+            })}
+          </>
+        ),
+        privileges: v.entities.map((e: any) => ({
+          roleName: v.role.name,
+          object: e.object.name,
+          objectName: e.object_name,
+          privilegeName: e.grantor.privilege.name,
+        })),
+      }))
+    );
   };
   };
 
 
-  const handleCreate = async (data: CreateRoleParams) => {
-    await UserHttp.createRole(data);
+  const onUpdate = async (data: { isEditing: boolean }) => {
     fetchRoles();
     fetchRoles();
     openSnackBar(successTrans('create', { name: userTrans('role') }));
     openSnackBar(successTrans('create', { name: userTrans('role') }));
     handleCloseDialog();
     handleCloseDialog();
   };
   };
 
 
-  const handleDelete = async () => {
+  const handleDelete = async (force?: boolean) => {
     for (const role of selectedRole) {
     for (const role of selectedRole) {
       const param: DeleteRoleParams = {
       const param: DeleteRoleParams = {
         roleName: role.name,
         roleName: role.name,
+        force,
       };
       };
       await UserHttp.deleteRole(param);
       await UserHttp.deleteRole(param);
     }
     }
@@ -65,8 +93,8 @@ const Roles = () => {
           type: 'custom',
           type: 'custom',
           params: {
           params: {
             component: (
             component: (
-              <CreateRole
-                handleCreate={handleCreate}
+              <UpdateRoleDialog
+                onUpdate={onUpdate}
                 handleClose={handleCloseDialog}
                 handleClose={handleCloseDialog}
               />
               />
             ),
             ),
@@ -76,6 +104,33 @@ const Roles = () => {
       icon: 'add',
       icon: 'add',
     },
     },
 
 
+    {
+      type: 'iconBtn',
+      label: userTrans('editRole'),
+      onClick: async () => {
+        setDialog({
+          open: true,
+          type: 'custom',
+          params: {
+            component: (
+              <UpdateRoleDialog
+                role={selectedRole[0]}
+                onUpdate={onUpdate}
+                handleClose={handleCloseDialog}
+              />
+            ),
+          },
+        });
+      },
+      icon: 'edit',
+      disabled: () =>
+        selectedRole.length === 0 ||
+        selectedRole.length > 1 ||
+        selectedRole.findIndex(v => v.name === 'admin') > -1 ||
+        selectedRole.findIndex(v => v.name === 'public') > -1,
+      disabledTooltip: userTrans('disableEditRolePrivilegeTip'),
+    },
+
     {
     {
       type: 'iconBtn',
       type: 'iconBtn',
       onClick: () => {
       onClick: () => {
@@ -89,6 +144,7 @@ const Roles = () => {
                 title={dialogTrans('deleteTitle', { type: userTrans('role') })}
                 title={dialogTrans('deleteTitle', { type: userTrans('role') })}
                 text={userTrans('deleteWarning')}
                 text={userTrans('deleteWarning')}
                 handleDelete={handleDelete}
                 handleDelete={handleDelete}
+                forceDelLabel={userTrans('forceDelLabel')}
               />
               />
             ),
             ),
           },
           },
@@ -97,9 +153,9 @@ const Roles = () => {
       label: '',
       label: '',
       disabled: () =>
       disabled: () =>
         selectedRole.length === 0 ||
         selectedRole.length === 0 ||
-        selectedRole.findIndex(v => v.name === 'root') > -1,
+        selectedRole.findIndex(v => v.name === 'admin') > -1 ||
+        selectedRole.findIndex(v => v.name === 'public') > -1,
       disabledTooltip: userTrans('deleteTip'),
       disabledTooltip: userTrans('deleteTip'),
-
       icon: 'delete',
       icon: 'delete',
     },
     },
   ];
   ];
@@ -111,6 +167,13 @@ const Roles = () => {
       disablePadding: false,
       disablePadding: false,
       label: userTrans('role'),
       label: userTrans('role'),
     },
     },
+
+    {
+      id: 'privilegeContent',
+      align: 'left',
+      disablePadding: false,
+      label: userTrans('privileges'),
+    },
   ];
   ];
 
 
   const handleSelectChange = (value: RoleData[]) => {
   const handleSelectChange = (value: RoleData[]) => {

+ 28 - 5
client/src/pages/user/Types.ts

@@ -46,17 +46,32 @@ export interface DeleteUserParams {
   username: string;
   username: string;
 }
 }
 
 
+export interface Privilege {
+  roleName: string;
+  object: string;
+  objectName: string;
+  privilegeName: string;
+}
+
 export interface CreateRoleParams {
 export interface CreateRoleParams {
   roleName: string;
   roleName: string;
+  privileges: Privilege[];
+}
+
+export interface RoleData {
+  name: string;
+  privileges: Privilege[];
 }
 }
 
 
 export interface CreateRoleProps {
 export interface CreateRoleProps {
-  handleCreate: (data: CreateRoleParams) => void;
+  onUpdate: (data: { data: CreateRoleParams; isEditing: boolean }) => void;
   handleClose: () => void;
   handleClose: () => void;
+  role?: RoleData;
 }
 }
 
 
 export interface DeleteRoleParams {
 export interface DeleteRoleParams {
   roleName: string;
   roleName: string;
+  force?: boolean;
 }
 }
 
 
 export interface AssignRoleParams {
 export interface AssignRoleParams {
@@ -66,13 +81,21 @@ export interface AssignRoleParams {
 
 
 export interface UnassignRoleParams extends AssignRoleParams {}
 export interface UnassignRoleParams extends AssignRoleParams {}
 
 
-export interface RoleData {
-  name: string;
-}
-
 export enum TAB_EMUM {
 export enum TAB_EMUM {
   'schema',
   'schema',
   'partition',
   'partition',
   'data-preview',
   'data-preview',
   'data-query',
   'data-query',
 }
 }
+
+export type RBACObject = 'Global' | 'Collection' | 'User';
+
+export interface PrivilegeOptionsProps {
+  options: string[];
+  selection: Privilege[];
+  onChange: (selection: Privilege[]) => void;
+  roleName: string;
+  object: RBACObject;
+  objectName?: string;
+  title: string;
+}

+ 1 - 1
client/src/pages/user/Update.tsx

@@ -10,7 +10,7 @@ import { UpdateUserParams, UpdateUserProps } from './Types';
 
 
 const useStyles = makeStyles((theme: Theme) => ({
 const useStyles = makeStyles((theme: Theme) => ({
   input: {
   input: {
-    margin: theme.spacing(3, 0, 0.5),
+    margin: theme.spacing(1, 0, 0.5),
   },
   },
 }));
 }));
 
 

+ 192 - 0
client/src/pages/user/UpdateRoleDialog.tsx

@@ -0,0 +1,192 @@
+import { makeStyles, Theme, Typography } from '@material-ui/core';
+import { FC, useMemo, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import DialogTemplate from '@/components/customDialog/DialogTemplate';
+import CustomInput from '@/components/customInput/CustomInput';
+import { ITextfieldConfig } from '@/components/customInput/Types';
+import { useFormValidation } from '@/hooks/Form';
+import { formatForm } from '@/utils/Form';
+import { UserHttp } from '@/http/User';
+import {
+  CreateRoleProps,
+  CreateRoleParams,
+  PrivilegeOptionsProps,
+} from './Types';
+import PrivilegeOptions from './PrivilegeOptions';
+
+const useStyles = makeStyles((theme: Theme) => ({
+  input: {
+    margin: theme.spacing(1, 0, 0.5),
+  },
+  dialogWrapper: {
+    maxWidth: theme.spacing(88),
+  },
+  checkBox: {
+    width: theme.spacing(24),
+  },
+  formGrp: {
+    marginBottom: theme.spacing(2),
+  },
+  subTitle: {
+    marginBottom: theme.spacing(0.5),
+  },
+}));
+
+const UpdateRoleDialog: FC<CreateRoleProps> = ({
+  onUpdate,
+  handleClose,
+  role = { name: '', privileges: [] },
+}) => {
+  const { t: userTrans } = useTranslation('user');
+  const { t: btnTrans } = useTranslation('btn');
+  const { t: warningTrans } = useTranslation('warning');
+  const [rbacOptions, setRbacOptions] = useState({
+    GlobalPrivileges: {},
+    CollectionPrivileges: {},
+    RbacObjects: {},
+    UserPrivileges: {},
+    Privileges: {},
+  });
+
+  const fetchRBAC = async () => {
+    const rbacOptions = await UserHttp.getRBAC();
+
+    setRbacOptions(rbacOptions);
+  };
+
+  const isEditing = role.name !== '';
+
+  useEffect(() => {
+    fetchRBAC();
+  }, []);
+
+  const [form, setForm] = useState<CreateRoleParams>({
+    roleName: role.name,
+    privileges: JSON.parse(JSON.stringify(role.privileges)),
+  });
+
+  const checkedForm = useMemo(() => {
+    return formatForm(form);
+  }, [form]);
+  const { validation, checkIsValid, disabled } = useFormValidation(checkedForm);
+
+  const classes = useStyles();
+
+  const handleInputChange = (key: 'roleName', value: string) => {
+    setForm(v => {
+      const newFrom = { ...v, [key]: value };
+
+      // update roleName
+      newFrom.privileges.forEach(p => (p.roleName = value));
+
+      return newFrom;
+    });
+  };
+
+  const createConfigs: ITextfieldConfig[] = [
+    {
+      label: userTrans('role'),
+      key: 'roleName',
+      onChange: (value: string) => handleInputChange('roleName', value),
+      variant: 'filled',
+      className: classes.input,
+      placeholder: userTrans('role'),
+      fullWidth: true,
+      validations: [
+        {
+          rule: 'require',
+          errorText: warningTrans('required', {
+            name: userTrans('role'),
+          }),
+        },
+      ],
+      defaultValue: form.roleName,
+      disabled: isEditing,
+    },
+  ];
+
+  const handleCreateRole = async () => {
+    if (!isEditing) {
+      await UserHttp.createRole(form);
+    }
+
+    await UserHttp.updateRolePrivileges(form);
+
+    onUpdate({ data: form, isEditing: isEditing });
+  };
+
+  const onChange = (newSelection: any) => {
+    setForm(v => ({ ...v, privileges: [...newSelection] }));
+  };
+
+  const optionGroups: PrivilegeOptionsProps[] = [
+    {
+      options: Object.values(rbacOptions.GlobalPrivileges) as string[],
+      object: 'Global',
+      title: userTrans('objectGlobal'),
+      selection: form.privileges,
+      roleName: form.roleName,
+      onChange: onChange,
+    },
+
+    {
+      options: Object.values(rbacOptions.CollectionPrivileges) as string[],
+      title: userTrans('objectCollection'),
+      object: 'Collection',
+      selection: form.privileges,
+      roleName: form.roleName,
+      onChange: onChange,
+    },
+
+    {
+      options: Object.values(rbacOptions.UserPrivileges) as string[],
+      title: userTrans('objectUser'),
+      object: 'User',
+      selection: form.privileges,
+      roleName: form.roleName,
+      onChange: onChange,
+    },
+  ];
+
+  return (
+    <DialogTemplate
+      title={userTrans(
+        isEditing ? 'updateRolePrivilegeTitle' : 'createRoleTitle'
+      )}
+      handleClose={handleClose}
+      confirmLabel={btnTrans(isEditing ? 'update' : 'create')}
+      handleConfirm={handleCreateRole}
+      confirmDisabled={disabled}
+      dialogClass={classes.dialogWrapper}
+    >
+      <>
+        {createConfigs.map(v => (
+          <CustomInput
+            type="text"
+            textConfig={v}
+            checkValid={checkIsValid}
+            validInfo={validation}
+            key={v.label}
+          />
+        ))}
+        <Typography variant="h5" component="h5" className={classes.subTitle}>
+          {userTrans('privileges')}
+        </Typography>
+
+        {optionGroups.map(o => (
+          <PrivilegeOptions
+            key={o.object}
+            title={o.title}
+            object={o.object}
+            options={o.options}
+            selection={o.selection}
+            roleName={o.roleName}
+            onChange={o.onChange}
+          />
+        ))}
+      </>
+    </DialogTemplate>
+  );
+};
+
+export default UpdateRoleDialog;

+ 1 - 1
client/src/pages/user/UpdateUserRole.tsx

@@ -13,7 +13,7 @@ import { UserHttp } from '@/http/User';
 
 
 const useStyles = makeStyles((theme: Theme) => ({
 const useStyles = makeStyles((theme: Theme) => ({
   input: {
   input: {
-    margin: theme.spacing(2, 0, 0.5),
+    margin: theme.spacing(1, 0, 0.5),
   },
   },
   dialogWrapper: {
   dialogWrapper: {
     maxWidth: theme.spacing(70),
     maxWidth: theme.spacing(70),

+ 1 - 1
server/package.json

@@ -12,7 +12,7 @@
     "url": "https://github.com/zilliztech/attu"
     "url": "https://github.com/zilliztech/attu"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@zilliz/milvus2-sdk-node": "2.2.22-beta.1",
+    "@zilliz/milvus2-sdk-node": "2.2.22-beta.2",
     "axios": "^1.4.0",
     "axios": "^1.4.0",
     "chalk": "^4.1.2",
     "chalk": "^4.1.2",
     "class-sanitizer": "^1.0.1",
     "class-sanitizer": "^1.0.1",

+ 0 - 1
server/src/milvus/milvus.service.ts

@@ -56,7 +56,6 @@ export class MilvusService {
         address: milvusAddress,
         address: milvusAddress,
         username,
         username,
         password,
         password,
-        logLevel: "debug"
       });
       });
 
 
       // don't break attu
       // don't break attu

+ 73 - 16
server/src/users/users.controller.ts

@@ -20,44 +20,45 @@ export class UserController {
   }
   }
 
 
   generateRoutes() {
   generateRoutes() {
+    // user
     this.router.get('/', this.getUsers.bind(this));
     this.router.get('/', this.getUsers.bind(this));
-
     this.router.post(
     this.router.post(
       '/',
       '/',
       dtoValidationMiddleware(CreateUserDto),
       dtoValidationMiddleware(CreateUserDto),
       this.createUsers.bind(this)
       this.createUsers.bind(this)
     );
     );
-
     this.router.put(
     this.router.put(
       '/',
       '/',
       dtoValidationMiddleware(UpdateUserDto),
       dtoValidationMiddleware(UpdateUserDto),
       this.updateUsers.bind(this)
       this.updateUsers.bind(this)
     );
     );
-
     this.router.delete('/:username', this.deleteUser.bind(this));
     this.router.delete('/:username', this.deleteUser.bind(this));
-
-    this.router.get('/roles', this.getRoles.bind(this));
-
-    this.router.post(
-      '/roles',
-      dtoValidationMiddleware(CreateRoleDto),
-      this.createRole.bind(this)
-    );
-
-    this.router.delete('/roles/:roleName', this.deleteRole.bind(this));
-
     this.router.put(
     this.router.put(
       '/:username/role/update',
       '/:username/role/update',
       dtoValidationMiddleware(AssignUserRoleDto),
       dtoValidationMiddleware(AssignUserRoleDto),
       this.updateUserRole.bind(this)
       this.updateUserRole.bind(this)
     );
     );
-
     this.router.put(
     this.router.put(
       '/:username/role/unassign',
       '/:username/role/unassign',
       dtoValidationMiddleware(UnassignUserRoleDto),
       dtoValidationMiddleware(UnassignUserRoleDto),
       this.unassignUserRole.bind(this)
       this.unassignUserRole.bind(this)
     );
     );
 
 
+    // role
+    this.router.get('/rbac', this.rbac.bind(this));
+    this.router.get('/roles', this.getRoles.bind(this));
+    this.router.post(
+      '/roles',
+      dtoValidationMiddleware(CreateRoleDto),
+      this.createRole.bind(this)
+    );
+    this.router.get('/roles/:roleName', this.listGrant.bind(this));
+    this.router.delete('/roles/:roleName', this.deleteRole.bind(this));
+    this.router.put(
+      '/roles/:roleName/updatePrivileges',
+      this.updateRolePrivileges.bind(this)
+    );
+
     return this.router;
     return this.router;
   }
   }
 
 
@@ -107,7 +108,15 @@ export class UserController {
 
 
   async getRoles(req: Request, res: Response, next: NextFunction) {
   async getRoles(req: Request, res: Response, next: NextFunction) {
     try {
     try {
-      const result = await this.userService.getRoles();
+      const result = (await this.userService.getRoles()) as any;
+
+      for (let i = 0; i < result.results.length; i++) {
+        const { entities } = await this.userService.listGrants({
+          roleName: result.results[i].role.name,
+        });
+        result.results[i].entities = entities;
+      }
+
       res.send(result);
       res.send(result);
     } catch (error) {
     } catch (error) {
       next(error);
       next(error);
@@ -126,7 +135,12 @@ export class UserController {
 
 
   async deleteRole(req: Request, res: Response, next: NextFunction) {
   async deleteRole(req: Request, res: Response, next: NextFunction) {
     const { roleName } = req.params;
     const { roleName } = req.params;
+    const { force } = req.body;
+
     try {
     try {
+      if (force) {
+        await this.userService.revokeAllRolePrivileges({ roleName });
+      }
       const result = await this.userService.deleteRole({ roleName });
       const result = await this.userService.deleteRole({ roleName });
       res.send(result);
       res.send(result);
     } catch (error) {
     } catch (error) {
@@ -187,4 +201,47 @@ export class UserController {
       next(error);
       next(error);
     }
     }
   }
   }
+
+  async rbac(req: Request, res: Response, next: NextFunction) {
+    try {
+      const result = await this.userService.getRBAC();
+      res.send(result);
+    } catch (error) {
+      next(error);
+    }
+  }
+
+  async listGrant(req: Request, res: Response, next: NextFunction) {
+    const { roleName } = req.params;
+    try {
+      const result = await this.userService.listGrants({
+        roleName: roleName,
+      });
+      res.send(result);
+    } catch (error) {
+      next(error);
+    }
+  }
+
+  async updateRolePrivileges(req: Request, res: Response, next: NextFunction) {
+    const { privileges } = req.body;
+    const { roleName } = req.params;
+
+    const results = [];
+
+    try {
+      // revoke all
+      this.userService.revokeAllRolePrivileges({ roleName });
+
+      // assign new user roles
+      for (let i = 0; i < privileges.length; i++) {
+        const result = await this.userService.grantRolePrivilege(privileges[i]);
+        results.push(result);
+      }
+
+      res.send(results);
+    } catch (error) {
+      next(error);
+    }
+  }
 }
 }

+ 53 - 0
server/src/users/users.service.ts

@@ -10,6 +10,13 @@ import {
   HasRoleReq,
   HasRoleReq,
   listRoleReq,
   listRoleReq,
   SelectUserReq,
   SelectUserReq,
+  Privileges,
+  GlobalPrivileges,
+  CollectionPrivileges,
+  UserPrivileges,
+  RbacObjects,
+  ListGrantsReq,
+  OperateRolePrivilegeReq,
 } from '@zilliz/milvus2-sdk-node';
 } from '@zilliz/milvus2-sdk-node';
 import { throwErrorFromSDK } from '../utils/Error';
 import { throwErrorFromSDK } from '../utils/Error';
 
 
@@ -87,4 +94,50 @@ export class UserService {
     throwErrorFromSDK(res.status);
     throwErrorFromSDK(res.status);
     return res;
     return res;
   }
   }
+
+  async getRBAC() {
+    return {
+      Privileges,
+      GlobalPrivileges,
+      CollectionPrivileges,
+      UserPrivileges,
+      RbacObjects,
+    };
+  }
+
+  async listGrants(data: ListGrantsReq) {
+    const res = await this.milvusService.client.listGrants(data);
+    throwErrorFromSDK(res.status);
+    return res;
+  }
+
+  async grantRolePrivilege(data: OperateRolePrivilegeReq) {
+    const res = await this.milvusService.client.grantRolePrivilege(data);
+    throwErrorFromSDK(res);
+    return res;
+  }
+
+  async revokeRolePrivilege(data: OperateRolePrivilegeReq) {
+    const res = await this.milvusService.client.revokeRolePrivilege(data);
+    throwErrorFromSDK(res);
+    return res;
+  }
+
+  async revokeAllRolePrivileges(data: { roleName: string }) {
+    // get existing privileges
+    const existingPrivileges = await this.listGrants({
+      roleName: data.roleName,
+    });
+
+    // revoke all
+    for (let i = 0; i < existingPrivileges.entities.length; i++) {
+      const res = existingPrivileges.entities[i];
+      await this.revokeRolePrivilege({
+        object: res.object.name,
+        objectName: res.object_name,
+        privilegeName: res.grantor.privilege.name,
+        roleName: res.role.name,
+      });
+    }
+  }
 }
 }

+ 4 - 4
server/yarn.lock

@@ -1180,10 +1180,10 @@
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.9.tgz#b6ef7457e826be8049667ae673eda7876eb049be"
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.9.tgz#b6ef7457e826be8049667ae673eda7876eb049be"
   integrity sha512-4VSbbcMoxc4KLjb1gs96SRmi7w4h1SF+fCoiK0XaQX62buCc1G5d0DC5bJ9xJBNPDSVCmIrcl8BiYxzjrqaaJA==
   integrity sha512-4VSbbcMoxc4KLjb1gs96SRmi7w4h1SF+fCoiK0XaQX62buCc1G5d0DC5bJ9xJBNPDSVCmIrcl8BiYxzjrqaaJA==
 
 
-"@zilliz/milvus2-sdk-node@2.2.21-beta.1":
-  version "2.2.22-beta.1"
-  resolved "https://registry.yarnpkg.com/@zilliz/milvus2-sdk-node/-/milvus2-sdk-node-2.2.22-beta.1.tgz#c58456c6c82bd7a5b34b3b3a6360d818233ad618"
-  integrity sha512-PrG1AWbv8HvHFbfKrXCbSsFFVLoWBfRfNcqJjSH1Lh5Av6RtWOuSz4wfO+7Ko78+vJ1RZtIe4zfFUezp83feEA==
+"@zilliz/milvus2-sdk-node@2.2.22-beta.2":
+  version "2.2.22-beta.2"
+  resolved "https://registry.yarnpkg.com/@zilliz/milvus2-sdk-node/-/milvus2-sdk-node-2.2.22-beta.2.tgz#e1fb9ab267252054b4f0b29c0f41d598faa24662"
+  integrity sha512-knz8YQKbT6LHblTbFILnC/XLZGIgzOpdPUFQuz27G10GDEIv6z6chxsNpHUwfGrQCW4eVz6DKtnSs1tnibfA5Q==
   dependencies:
   dependencies:
     "@grpc/grpc-js" "1.8.17"
     "@grpc/grpc-js" "1.8.17"
     "@grpc/proto-loader" "0.7.7"
     "@grpc/proto-loader" "0.7.7"