RBAC权限管理

2019-11-102776

近 2 年一直使用蚂蚁金服的 Ant Design UI 框架以及其开箱即用的中台前端/设计解决方案 ANT DESIGN PRO (去年的圣诞风波有点影响,希望不再发生类似的事情),框架是一直更新一直迭代,不过里面涉及权限管理的部分的使用场景还是比较有限,兼容不了需要细化到各模块中的具体动作的场景。授人以鱼不如授人以渔,没有就自己撸一个呗。

设计思想

虽然是自己撸,但还是得站在前辈的肩膀上,离开设计的代码都不够优雅。向我司的校长(霸气绰号,具体为什么叫校长可以在 https://www.luweitech.cn/ 上找找,可能能找到 (#^.^#))学习——写代码要写得像诗一样优雅。

找了一圈,最终选了一个设计思想——RBAC,RBAC 以角色为基础的访问控制(英语:Role-based access control,RBAC),简单可以归纳为 who、what、how,即,who 对 what进行了 how 的操作,翻译成广东话就系:“有一个靓仔企一个野里面做左滴野”。

一张简单的图(图是盗来的~)理解:

即,张三、李四是“销售角色”,而“销售角色拥有查看“客户列表”和“编辑客户”两个动作的权限,自然而然的,张三、李四就拥有查看“客户列表”和“编辑客户”两个动作的权限。

完整一点就是(图也是盗来的):

由上可以看出,核心就三步:

  1. 定义角色
  2. 授权角色拥有的权限
  3. 给用户指定角色

准备

我觉得核心还是上面的设计思路,具体的代码实现只是思路的表达,后续封装得更通用再放出完整版出来吧。

ps:使用的 ant-design-pro 版本是 2.2.1,有比较多旧系统,还没一下子升级到最新的,各位可以用最新来撸

授权角色拥有的权限

定义角色这一步比较简单,就直接跳过了~

先说第二步,给角色授权权限。先上效果图:

这一步有几个关键步骤:

  1. router.config.js 转化成上图中用于展示数据
  2. 构建好上图中的交互逻辑
  3. 把用户选择的权限按特定格式发给后台

一部分 router.config.js,如下

export default [
  // user
  ...节省位置,省略

  // app
  {
    path: '/',
    component: '../layouts/BasicLayout',
    Routes: ['src/pages/Authorized'],
    routes: [
      {
        path: '/',
        redirect: '/welcome',
      },

      {
        name: 'welcome',
        path: '/welcome',
        icon: 'smile',
        component: './Welcome/Welcome',
        power: ['MENU'],
      },

      {
        name: 'revenueManagement',
        path: '/revenueManagement',
        icon: 'pay-circle',
        power: ['MENU'],
        routes: [
          { 
              name: 'userDeposit',
              path: '/revenueManagement/userDeposit',
              component: './UserDeposit/UserDeposit',
              power: ['MENU', 'CONTENT', 'EXPORT'],
          },
          {
            name: 'userConsumptions',
            path: '/revenueManagement/userConsumptions',
            component: './UserConsumptions/UserConsumptions',
            power: ['MENU', 'CONTENT', 'EXPORT'],
          },
          { 
            name: 'staffTuningLogs',
            path: '/revenueManagement/staffTuningLogs',
            component: './StaffTuningLogs/StaffTuningLogs',
            power: ['MENU', 'CONTENT', 'EXPORT'],
          },
          { 
            name: 'userAccount',
            path: '/revenueManagement/userAccount',
            component: './UserAccount/UserAccount',
            power: ['MENU', 'CONTENT', 'EXPORT', 'GIVE_COIN'],
          },
        ]
      },
    ],
  },
];

比较关键是准备这几个数据:(聪明的你肯定知道 _lodash)

/**
 * 过滤原始的 router 数据,返回有 power 属性的 item
 * @param {Array} data router.config.js 中关于 app 部分的配置,即:RouterConfig[1].routes,注意,不要直接把 RouterConfig[1].routes 传递进来,这里会改变原来的数据,所以需要深复制后才传进来
 * @returns {Array} 格式化后的 RouterConfig[1].routes,过滤掉没有 power 属性的 item
 */
function filterRouter(data) {
  return data.filter((item) => {
    if (item.routes) {
      item.routes = filterRouter(item.routes);
    }

    return item.power;
  })
}

/**
 * 将 filterRouter且memoizeOneFormatter 出来后的数据的 power 属性改成 [{label: "查看菜单", value: "MENU"}] 的形式,用于在展示是可以出现中文
 * @param {Array} data RouterConfig[1].routes执行 filterRouter且memoizeOneFormatter 函数后的数据,同样,该参数需要深复制后才传递进来
 * @returns {Array} 修改 power 属性后的数据
 */
function setPowerText(data) {
  return data.map((item) => {
    if (item.children) {
      item.children = setPowerText(item.children);
    }

    item.power = item.power.map((powerItem) => {
      return {
        label: powerName[powerItem],
        value: powerItem,
      }
    });

    return item;
  });
}

/**
 * path 为 key,power 为 value,将 filterRouter且memoizeOneFormatter 后的数据,转成这种 key-value 的对象
 * @param {Array} data RouterConfig[1].routes执行 filterRouter且memoizeOneFormatter 函数后的数据,同样,该参数需要深复制后才传递进来
 * @returns {Object} 
 * 例如:
    {
      '/list': ['MENU'],
      '/list/basic-list': ['MENU', 'CONTENT', 'ADD', 'UPDATE', 'DELETE'],
      '/exception': ['MENU'],
    }
 */
function getAllPowerKeyValue(data) {
  let result = {};

  const recursion = (data) => {
    data.forEach((item) => {
      result[item.path] = item.power;

      if (item.children) {
        recursion(item.children);
      }
    });
  }

  recursion(data);

  return result;
}

const powerOriginData = filterRouter(_.cloneDeep(RouterConfig[1].routes)); // 过滤没有 power 属性的项
const localePowerOriginData = memoizeOneFormatter(powerOriginData, undefined); // 将name 改成相应语言,注意,经过这个函数之后,原本的 routes 就改成 children 了
const powerTextData = setPowerText(_.cloneDeep(localePowerOriginData));
const allPowerKeyValueData = getAllPowerKeyValue(_.cloneDeep(localePowerOriginData));

powerOriginData 是过滤掉没有 power (power 是自己定义的一个属性,用来标明该模块中拥有哪些动作) 属性的项,减少接下来计算中的次数。

powerTextData 纯粹是为了展示用的,把动作的标识换成中文给用户选择时看

allPowerKeyValueData 主要是为了方便接下来的计算,把 router.config.js 中多余的字段都清掉,留下 key(以模块的 path 为 key)和对应的 power。

准备好这些展示数据,后面的交互逻辑和发送给后台就简单了,不啰嗦了~

使用

  1. 定义角色
  2. 授权角色拥有的权限
  3. 给用户指定角色

完成以上三步后,下一个模块就是直接使用了,这里分成两个部分:

  1. 登录时获取该用户的权限并初始化侧边栏
  2. 给各模块中的动作上锁

登录时拦截

  1. 登录后,结合当前用户信息,再向后台的接口请求数据,获取当前用户的所有权限
    • 比如,如果后台返回的数据如下(第 2 步下面)
    • 获得后台返回的数据后,将以上数据格式化成: {/authority: ["MENU"], /authority/role: ["MENU", "CONTENT", "ADD", "UPDATE", "TRIGGER", "RESOURCE_AUTHORIZE"]},标志每个路由(页面)里面匹配当前用户的角色分别有哪些权限,然后存在local storage中,字段命名为:curStaffAuthorized
  2. 进入主页面后,加载 src/layouts/BasicLayout.js 组件时会构造侧边栏,在 src/models/menu.jsgetMenuData 将以上缓存中 curStaffAuthorized 的数据转换成侧边栏的数据,过程如下:(具体可以查看:v2.0 权限控制
    • getMenuDatapayload 参数中有一个 routesconfig/router.config.js 中的所有路由
    • 结合缓存中 curStaffAuthorized 的数据就能知道当前用户哪些路由是有权限的,哪些路由没有权限,直接把没有权限的路由从要渲染到侧边栏的数据中删掉
后台返回的格式:("/authority"--这个 key 是路由,代表该路由或该页面有哪些权限)
{
  "/authority":[{permission_id: 1, action: "MENU", name: "角色权限管理-角色管理-MENU", description: ""}],
  "/authority/role":[
    {permission_id: 2, action: "MENU", name: "角色权限管理-角色管理-MENU", description: ""},
    {permission_id: 3, action: "CONTENT", name: "角色权限管理-角色管理-CONTENT", description: ""},
  ]
}

给各模块中的动作上锁

这一步就比较简单了(不过很麻烦,在想有没有更好的办法)

在 pages 中,根据 pathcurStaffAuthorized检查是否有该权限,然后根据标识控制对应功能的显示与否,比如:

let path = props.match.path;
this.contentPower = checkPower(CONTENT, path);
this.addPower = checkPower(ADD, path);
this.updatePower = checkPower(UPDATE, path);
this.deletePower = checkPower(DELETE, path);
this.triggerPower = checkPower(TRIGGER, path);

{this.addPower && <Button icon="plus" type="primary" onClick={this.handleAddClick}>新建</Button>}

分享
点赞4
打赏
上一篇:代理工具Fiddler -调试与替换接口状态
下一篇:Less 初试