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,
   Theme,
   Typography,
+  Checkbox,
+  FormControlLabel,
 } from '@material-ui/core';
 import { ChangeEvent, FC, useContext, useState } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -36,16 +38,19 @@ const useStyles = makeStyles((theme: Theme) => ({
   cancelBtn: {
     color: theme.palette.attuGrey.dark,
   },
+  checkBox: {},
 }));
 
 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 classes = useStyles();
   const { t: dialogTrans } = useTranslation('dialog');
   const { t: btnTrans } = useTranslation('btn');
 
   const [value, setValue] = useState<string>('');
+  const [force, setForce] = useState<boolean>(false);
   const [deleteReady, setDeleteReady] = useState<boolean>(false);
 
   const onCancelClick = () => {
@@ -54,7 +59,7 @@ const DeleteTemplate: FC<DeleteDialogContentType> = props => {
   };
 
   const onDeleteClick = (event: React.FormEvent<HTMLFormElement>) => {
-    handleDelete();
+    handleDelete(force);
     event.preventDefault();
   };
 
@@ -100,6 +105,25 @@ const DeleteTemplate: FC<DeleteDialogContentType> = props => {
             variant="filled"
             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>
 
         <DialogActions className={classes.btnWrapper}>

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

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

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

@@ -70,8 +70,9 @@ export default class BaseModel {
   }
 
   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;
   }

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

@@ -17,50 +17,73 @@ export class UserHttp extends BaseModel {
     Object.assign(this, props);
   }
 
-  static USER_URL = `/users`;
+  static USERS_URL = `/users`;
+  static ROLES_URL = `/users/roles`;
 
+  // get user data
   static getUsers() {
-    return super.search({ path: this.USER_URL, params: {} });
+    return super.search({ path: this.USERS_URL, params: {} });
   }
 
+  // create user
   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) {
-    return super.update({ path: this.USER_URL, data });
+    return super.update({ path: this.USERS_URL, data });
   }
 
+  // delete user
   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) {
-    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({
-      path: `${this.USER_URL}/${data.username}/role/unassign`,
+      path: `${this.ROLES_URL}/${data.roleName}/updatePrivileges`,
       data,
     });
   }
 
+  // get RBAC info
+  static getRBAC() {
+    return super.search({ path: `${this.USERS_URL}/rbac`, params: {} });
+  }
+
   get _names() {
     return this.names;
   }

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

@@ -12,12 +12,26 @@ const userTrans = {
   isNotSame: 'Not same as new password',
   deleteTip:
     'Please select at least one item to drop and the root user can not be dropped.',
+
+  // role
   deleteEditRoleTip: 'root role is not editable.',
+  disableEditRolePrivilegeTip: 'admin and public role are not editable.',
+
   role: 'Role',
   editRole: 'Edit Role',
   roles: 'Roles',
   createRoleTitle: 'Create Role',
+  updateRolePrivilegeTitle: 'Update Role',
   updateRoleSuccess: 'User Role',
+  type: 'Type',
+
+  // Privileges
+  privileges: 'Privileges',
+  objectCollection: 'Collection',
+  objectGlobal: 'Global',
+  objectUser: 'User',
+
+  forceDelLabel: 'Force delete, revoke all privileges.',
 };
 
 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) => ({
   input: {
-    margin: theme.spacing(2, 0, 0.5),
+    margin: theme.spacing(1, 0, 0.5),
   },
   dialogWrapper: {
     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 { UserHttp } from '@/http/User';
 import AttuGrid from '@/components/grid/Grid';
 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 { rootContext } from '@/context/Root';
 import { useNavigationHook } from '@/hooks/Navigation';
 import { ALL_ROUTER_TYPES } from '@/router/Types';
-import CreateRole from './CreateRole';
+import UpdateRoleDialog from './UpdateRoleDialog';
 
 const useStyles = makeStyles((theme: Theme) => ({
   wrapper: {
     height: `calc(100vh - 160px)`,
   },
+  chip: {
+    marginRight: theme.spacing(0.5),
+  },
 }));
 
 const Roles = () => {
@@ -32,21 +35,46 @@ const Roles = () => {
 
   const fetchRoles = async () => {
     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();
     openSnackBar(successTrans('create', { name: userTrans('role') }));
     handleCloseDialog();
   };
 
-  const handleDelete = async () => {
+  const handleDelete = async (force?: boolean) => {
     for (const role of selectedRole) {
       const param: DeleteRoleParams = {
         roleName: role.name,
+        force,
       };
       await UserHttp.deleteRole(param);
     }
@@ -65,8 +93,8 @@ const Roles = () => {
           type: 'custom',
           params: {
             component: (
-              <CreateRole
-                handleCreate={handleCreate}
+              <UpdateRoleDialog
+                onUpdate={onUpdate}
                 handleClose={handleCloseDialog}
               />
             ),
@@ -76,6 +104,33 @@ const Roles = () => {
       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',
       onClick: () => {
@@ -89,6 +144,7 @@ const Roles = () => {
                 title={dialogTrans('deleteTitle', { type: userTrans('role') })}
                 text={userTrans('deleteWarning')}
                 handleDelete={handleDelete}
+                forceDelLabel={userTrans('forceDelLabel')}
               />
             ),
           },
@@ -97,9 +153,9 @@ const Roles = () => {
       label: '',
       disabled: () =>
         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'),
-
       icon: 'delete',
     },
   ];
@@ -111,6 +167,13 @@ const Roles = () => {
       disablePadding: false,
       label: userTrans('role'),
     },
+
+    {
+      id: 'privilegeContent',
+      align: 'left',
+      disablePadding: false,
+      label: userTrans('privileges'),
+    },
   ];
 
   const handleSelectChange = (value: RoleData[]) => {

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

@@ -46,17 +46,32 @@ export interface DeleteUserParams {
   username: string;
 }
 
+export interface Privilege {
+  roleName: string;
+  object: string;
+  objectName: string;
+  privilegeName: string;
+}
+
 export interface CreateRoleParams {
   roleName: string;
+  privileges: Privilege[];
+}
+
+export interface RoleData {
+  name: string;
+  privileges: Privilege[];
 }
 
 export interface CreateRoleProps {
-  handleCreate: (data: CreateRoleParams) => void;
+  onUpdate: (data: { data: CreateRoleParams; isEditing: boolean }) => void;
   handleClose: () => void;
+  role?: RoleData;
 }
 
 export interface DeleteRoleParams {
   roleName: string;
+  force?: boolean;
 }
 
 export interface AssignRoleParams {
@@ -66,13 +81,21 @@ export interface AssignRoleParams {
 
 export interface UnassignRoleParams extends AssignRoleParams {}
 
-export interface RoleData {
-  name: string;
-}
-
 export enum TAB_EMUM {
   'schema',
   'partition',
   'data-preview',
   '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) => ({
   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) => ({
   input: {
-    margin: theme.spacing(2, 0, 0.5),
+    margin: theme.spacing(1, 0, 0.5),
   },
   dialogWrapper: {
     maxWidth: theme.spacing(70),

+ 1 - 1
server/package.json

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

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

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

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

@@ -20,44 +20,45 @@ export class UserController {
   }
 
   generateRoutes() {
+    // user
     this.router.get('/', this.getUsers.bind(this));
-
     this.router.post(
       '/',
       dtoValidationMiddleware(CreateUserDto),
       this.createUsers.bind(this)
     );
-
     this.router.put(
       '/',
       dtoValidationMiddleware(UpdateUserDto),
       this.updateUsers.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(
       '/:username/role/update',
       dtoValidationMiddleware(AssignUserRoleDto),
       this.updateUserRole.bind(this)
     );
-
     this.router.put(
       '/:username/role/unassign',
       dtoValidationMiddleware(UnassignUserRoleDto),
       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;
   }
 
@@ -107,7 +108,15 @@ export class UserController {
 
   async getRoles(req: Request, res: Response, next: NextFunction) {
     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);
     } catch (error) {
       next(error);
@@ -126,7 +135,12 @@ export class UserController {
 
   async deleteRole(req: Request, res: Response, next: NextFunction) {
     const { roleName } = req.params;
+    const { force } = req.body;
+
     try {
+      if (force) {
+        await this.userService.revokeAllRolePrivileges({ roleName });
+      }
       const result = await this.userService.deleteRole({ roleName });
       res.send(result);
     } catch (error) {
@@ -187,4 +201,47 @@ export class UserController {
       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,
   listRoleReq,
   SelectUserReq,
+  Privileges,
+  GlobalPrivileges,
+  CollectionPrivileges,
+  UserPrivileges,
+  RbacObjects,
+  ListGrantsReq,
+  OperateRolePrivilegeReq,
 } from '@zilliz/milvus2-sdk-node';
 import { throwErrorFromSDK } from '../utils/Error';
 
@@ -87,4 +94,50 @@ export class UserService {
     throwErrorFromSDK(res.status);
     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"
   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:
     "@grpc/grpc-js" "1.8.17"
     "@grpc/proto-loader" "0.7.7"