Browse Source

rbac part1

Signed-off-by: shanghaikid <jiangruiyi@gmail.com>
shanghaikid 1 year ago
parent
commit
c8f0a94362

+ 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}` });
   }
 
-  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;
   }

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

@@ -12,12 +12,21 @@ 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.',
   role: 'Role',
   editRole: 'Edit Role',
   roles: 'Roles',
   createRoleTitle: 'Create Role',
   updateRoleSuccess: 'User Role',
+  type: 'Type',
+
+  // Privileges
+  privileges: 'Privileges',
+  objectCollection: 'Collection',
+  objectGlobal: 'Global',
+  objectUser: 'User',
 };
 
 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;

+ 271 - 0
client/src/pages/user/CreateRoleDialog.tsx

@@ -0,0 +1,271 @@
+import {
+  makeStyles,
+  Theme,
+  Typography,
+  Checkbox,
+  FormGroup,
+  FormControlLabel,
+} 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, Privilege } from './Types';
+
+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 CreateRoleDialog: FC<CreateRoleProps> = ({ onCreate, handleClose }) => {
+  const { t: commonTrans } = useTranslation();
+  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();
+    const roles = await UserHttp.getRoles();
+
+    console.log(rbacOptions, roles);
+
+    setRbacOptions(rbacOptions);
+  };
+
+  useEffect(() => {
+    fetchRBAC();
+  }, []);
+
+  const [form, setForm] = useState<CreateRoleParams>({
+    roleName: '',
+    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,
+    },
+  ];
+
+  const handleCreateRole = async () => {
+    await UserHttp.createRole(form);
+    await UserHttp.updateRolePrivileges(form);
+
+    onCreate(form);
+  };
+
+  // prepare data
+  const globalPriviledgeOptions = Object.values(rbacOptions.GlobalPrivileges);
+  const collectionPrivilegeOptions = Object.values(
+    rbacOptions.CollectionPrivileges
+  );
+  const userPrivilegeOptions = Object.values(rbacOptions.UserPrivileges);
+
+  return (
+    <DialogTemplate
+      title={userTrans('createRoleTitle')}
+      handleClose={handleClose}
+      confirmLabel={btnTrans('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>
+
+        <Typography variant="h6" component="h6" className={classes.subTitle}>
+          {userTrans('objectGlobal')}
+        </Typography>
+
+        <FormGroup row className={classes.formGrp}>
+          {globalPriviledgeOptions.map((r: any, index: number) => (
+            <FormControlLabel
+              control={
+                <Checkbox
+                  onChange={(
+                    e: React.ChangeEvent<HTMLInputElement>,
+                    checked: boolean
+                  ) => {
+                    let newPivilegs = [...form.privileges];
+
+                    if (!checked) {
+                      newPivilegs = newPivilegs.filter(
+                        (n: Privilege) => n.privilegeName !== r
+                      );
+                    } else {
+                      newPivilegs.push({
+                        privilegeName: r,
+                        object: 'Global',
+                        objectName: '*',
+                        roleName: form.roleName,
+                      });
+                    }
+                    console.log(newPivilegs);
+
+                    setForm(v => ({ ...v, privileges: [...newPivilegs] }));
+                  }}
+                />
+              }
+              key={r}
+              label={r}
+              value={r}
+              className={classes.checkBox}
+            />
+          ))}
+        </FormGroup>
+
+        <Typography variant="h6" component="h6" className={classes.subTitle}>
+          {userTrans('objectCollection')}
+        </Typography>
+
+        <FormGroup row className={classes.formGrp}>
+          {collectionPrivilegeOptions.map((r: any, index: number) => (
+            <FormControlLabel
+              control={
+                <Checkbox
+                  onChange={(
+                    e: React.ChangeEvent<HTMLInputElement>,
+                    checked: boolean
+                  ) => {
+                    let newPivilegs = [...form.privileges];
+
+                    if (!checked) {
+                      newPivilegs = newPivilegs.filter(
+                        (n: Privilege) => n.privilegeName !== r
+                      );
+                    } else {
+                      newPivilegs.push({
+                        privilegeName: r,
+                        object: 'Collection',
+                        objectName: '*',
+                        roleName: form.roleName,
+                      });
+                    }
+                    console.log(newPivilegs);
+
+                    setForm(v => ({ ...v, privileges: [...newPivilegs] }));
+                  }}
+                />
+              }
+              key={r}
+              label={r}
+              value={r}
+              className={classes.checkBox}
+            />
+          ))}
+        </FormGroup>
+
+        <Typography variant="h6" component="h6" className={classes.subTitle}>
+          {userTrans('objectUser')}
+        </Typography>
+
+        <FormGroup row className={classes.formGrp}>
+          {userPrivilegeOptions.map((r: any, index: number) => (
+            <FormControlLabel
+              control={
+                <Checkbox
+                  onChange={(
+                    e: React.ChangeEvent<HTMLInputElement>,
+                    checked: boolean
+                  ) => {
+                    let newPivilegs = [...form.privileges];
+
+                    if (!checked) {
+                      newPivilegs = newPivilegs.filter(
+                        (n: Privilege) => n.privilegeName !== r
+                      );
+                    } else {
+                      newPivilegs.push({
+                        privilegeName: r,
+                        object: 'User',
+                        objectName: '*',
+                        roleName: form.roleName,
+                      });
+                    }
+
+                    console.log(newPivilegs);
+
+                    setForm(v => ({ ...v, privileges: [...newPivilegs] }));
+                  }}
+                />
+              }
+              key={r}
+              label={r}
+              value={r}
+              className={classes.checkBox}
+            />
+          ))}
+        </FormGroup>
+      </>
+    </DialogTemplate>
+  );
+};
+
+export default CreateRoleDialog;

+ 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),

+ 5 - 9
client/src/pages/user/Roles.tsx

@@ -1,15 +1,15 @@
-import React, { useContext, useEffect, useState } from 'react';
+import { useContext, useEffect, useState } from 'react';
 import { makeStyles, Theme } 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 CreateRole from './CreateRoleDialog';
 
 const useStyles = makeStyles((theme: Theme) => ({
   wrapper: {
@@ -36,8 +36,7 @@ const Roles = () => {
     setRoles(roles.results.map((v: any) => ({ name: v.role.name })));
   };
 
-  const handleCreate = async (data: CreateRoleParams) => {
-    await UserHttp.createRole(data);
+  const onCreate = async () => {
     fetchRoles();
     openSnackBar(successTrans('create', { name: userTrans('role') }));
     handleCloseDialog();
@@ -65,10 +64,7 @@ const Roles = () => {
           type: 'custom',
           params: {
             component: (
-              <CreateRole
-                handleCreate={handleCreate}
-                handleClose={handleCloseDialog}
-              />
+              <CreateRole onCreate={onCreate} handleClose={handleCloseDialog} />
             ),
           },
         });

+ 9 - 1
client/src/pages/user/Types.ts

@@ -46,12 +46,20 @@ 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 CreateRoleProps {
-  handleCreate: (data: CreateRoleParams) => void;
+  onCreate: (data: CreateRoleParams) => void;
   handleClose: () => void;
 }
 

+ 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),
   },
 }));
 

+ 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",

+ 83 - 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);
@@ -187,4 +196,62 @@ 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 {
+      // get existing privileges
+      const existingPrivileges = await this.userService.listGrants({
+        roleName,
+      });
+
+      console.log('existing privileges', existingPrivileges);
+
+      // const existingPrivileges = privileges.results[0].roles;
+      // // remove user existing roles
+      // for (let i = 0; i < existingRoles.length; i++) {
+      //   if (existingRoles[i].name.length > 0) {
+      //     await this.userService.unassignUserRole({
+      //       username,
+      //       roleName: existingRoles[i].name,
+      //     });
+      //   }
+      // }
+
+      // 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);
+    }
+  }
 }

+ 29 - 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,26 @@ 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;
+  }
 }

+ 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"