权限设计模式(前端 RBAC:运行时配置 + 路由角色准入 + 按钮级权限点)

本文基于一个真实的中后台项目落地经验,总结一套“企业级可演进”的前端权限设计模式:在后端暂时无法改造的前提下,用前端 RBAC 把“能不能进模块 / 菜单显示 / 按钮能不能点 / 弹窗能不能提交”统一收敛到一套可配置、可扩展、可测试的体系里。

本文既是设计思路讲解,也给出可直接复制的实现范式(本仓库的实现为 React 版本;后续将补充 Vue/React 的可运行示例代码目录)。


0. 先说结论:这套模式解决了什么

在中后台里,权限通常会分散在三类地方:

  • 路由:能不能进某个模块
  • 菜单:能不能看到某个入口
  • 页面:按钮/操作列/表单是否可操作

如果你把“权限”只做成 if (roles.includes('ops')) 之类的硬编码,随着角色增加(例如 ops-viewer、ops-auditor、finance…),你会面临:

  • 判断散落在项目各处,难以统一修改
  • 新增角色时需要全局搜改,回归风险巨大
  • 读写权限难以表达(只读角色常见)
  • 前端体验与后端鉴权容易脱节(但后端又不一定能及时配合)

本文介绍的模式的关键点是:

  1. 路由级:仍可保留“角色数组”作为准入(快速、粗粒度、能控住模块边界)
  2. 按钮级:引入“权限点 code”(细粒度动作能力),统一用 useCan()/AuthButton 判断
  3. 配置级:把 role -> permissions 映射放到 rbac.json 里,支持运行时加载与回退,做到“改配置即可调权限”
  4. 可演进:未来后端可改造时,可以把权限点下发到 /me,前端接入点不变,只替换数据源

重要声明:前端权限不是安全边界。它的核心目标是统一体验、减少误操作、降低维护成本;任何写接口的安全性必须由后端鉴权兜底。


1. 权限的分层模型(企业级推荐)

1.1 三层权限:路由 / 菜单 / 动作

在工程实践中,把权限明确分层可以显著降低复杂度:

  • 路由准入(Route Guard):控制“能不能进入某个模块/页面”
  • 菜单可见(Menu Visibility):控制“能不能看到入口”
  • 动作权限(Action Permission):控制“按钮是否可点、提交是否允许、操作列是否可用、表单是否只读”

其中:

  • 路由准入更适合使用“角色数组”判断(粗粒度)
  • 动作权限更适合使用“权限点 code”判断(细粒度)
  • 菜单可见通常跟路由准入一致,但在一些企业场景会“可见但不可进”或“可进但不可见”(例如直接跳转链接),所以最好独立处理

1.2 RBAC 的正确抽象:Role -> Permission

推荐不要在业务代码里写“角色判断”,而是统一写“权限点判断”:

  • Role(角色):一类人(ops、ops-viewer)
  • Permission Code(权限点):一个动作能力(customer.update、recharge.write)
  • Role-Permission Mapping:角色拥有哪些权限点(可配置)

这样你新增角色时,只需要维护“角色拥有哪些权限点”,而不是全项目搜改 if/else。


2. 本项目的落地结构(工程视角)

2.1 文件结构(核心入口)

这套模式在本仓库的关键文件如下(此为原项目目录,非示例代码结构):

  • 运行时配置文件:public/rbac.json
  • 配置加载与兜底:src/store/authz.ts
  • 权限匹配核心(支持 * 通配):src/authz/rbac.ts
  • React Hook 入口:src/authz/useCan.ts
  • 按钮封装组件:src/authz/AuthButton.tsx
  • 路由守卫(同时负责拉取 /me):src/router/AuthRoute.tsx
  • 路由表(模块准入用 roles 数组):src/router/index.tsx
  • 菜单过滤(模块入口可见性用 roles 数组):src/layouts/BasicLayout.tsx

2.2 运行时数据流(从登录到按钮权限)

一个典型的页面进入流程可以抽象成:

登录成功 -> token 持久化
     |
进入受保护路由(AuthRoute)
     |
 1) 拉取 /v1/oauth/me 得到 user + roles(后端返回)
 2) 拉取 /rbac.json 得到 role->permissions(静态配置)
     |
路由级:handle.permissions(roles)决定能否进入
按钮级:useCan/AuthButton(permission code)决定可见/可点/可提交

注意:这套前端 RBAC 的实现不要求后端返回 permissions,只要后端能返回 roles(哪怕只是一个 ops-viewer),前端就能用本地映射推导出动作能力。


3. 配置:rbac.json 怎么写、怎么匹配

3.1 配置文件格式(role -> permissions)

public/rbac.json 的结构(示意):

{
  "version": 1,
  "roles": {
    "guest": { "permissions": [] },
    "ops-viewer": { "permissions": ["operations.*.read"] },
    "ops": { "permissions": ["operations.*"] },
    "super": { "permissions": ["*"] }
  }
}

字段含义:

  • version:版本号(目前用于校验;未来可做灰度/兼容)
  • roles:以角色为 key 的对象
  • roles[role].permissions:该角色拥有的权限点/通配模式

3.2 通配符规则(非常关键)

本项目支持 * 通配符,语义是“任意字符任意长度(包含 .)”,并采用全字符串匹配。

举例:

  • operations.* 匹配 operations.customers.update、operations.customers.recharge.write
  • operations.*.read 匹配 operations.customers.read、operations.customers.pricing.read
  • * 匹配所有权限点(通常只给 super)

实现入口:src/authz/rbac.ts 中的 matchPermission()。

3.3 为什么用“运行时配置 + 兜底”

运行时配置最大的价值是:你可以不改 TS 代码,只改一个静态 JSON 即可调整权限,非常适合:

  • 后端暂时无法配合(常见于跨团队依赖、排期卡点)
  • 需要快速 A/B 验证或紧急调整权限策略
  • 希望测试能自己切换“只读/可写”角色验证体验

兜底策略也很重要:

  • /rbac.json 加载失败:不要阻断页面渲染(否则会造成全站不可用)
  • 配置不合法:回退到内置默认配置(defaultRbacConfig)

实现入口:src/store/authz.ts。


4. 权限点(Permission Code)怎么设计:命名规范与边界

4.1 命名规范(建议)

推荐使用层级命名,便于通配与治理:

{domain}.{resource}.{action}[.{scope}]

例如:

  • operations.customers.read(读)
  • operations.customers.create(新增)
  • operations.customers.update(编辑)
  • operations.customers.recharge.read(查看充值)
  • operations.customers.recharge.write(充值写入)

为什么要区分 read/write:

  • 只读角色(如 ops-viewer)非常常见
  • 用 *.read 的通配能一键覆盖整个域的“查看能力”
  • 写权限更容易被误授,需要更慎重、颗粒度更细

4.2 权限点边界(不要过细,也不要过粗)

企业级实践建议:

  1. 先按“模块/资源”划分:operations.customers.*
  2. 再按“动作类型”划分:read/create/update/delete/…
  3. 需要表达“写能力集合”时,引入 *.write(聚合多个写动作)

不要一开始就过度细化到字段级别(那是 ABAC/Policy 的领域),否则管理成本会很快失控。


5. 前端接入范式(最重要:怎么在代码里用)

这套模式的核心目标之一就是:让开发者在页面里用一种很一致的写法接入权限。

5.1 useCan:纯判断(适合提交兜底)

import { useCan } from '@/authz';
import { message } from 'antd';

const { allowed: canUpdate } = useCan('operations.customers.update');

const handleSubmit = async () => {
  if (!canUpdate) {
    message.warning('无权限');
    return;
  }
  // ...真实提交逻辑
};

适用场景:

  • 表单提交
  • Modal 的 onOk
  • 批量操作、导入导出等非按钮直达逻辑

5.2 AuthButton:按钮封装(推荐)

import { AuthButton } from '@/authz';

<AuthButton
  type="primary"
  perm="operations.*.create"
  denyText="只读角色无权限"
  onClick={() => setOpen(true)}
>
  新增客户
</AuthButton>

关键点:

  • perm 是权限点 code
  • 默认 mode="disable"(显示但不可点),也支持 mode="hide"(直接隐藏)
  • denyText 用 tooltip 解释“为什么不可点”,减少用户困惑与客服成本

5.3 为什么“按钮禁用”还要“提交兜底”

企业级系统里必须默认“攻击者存在”,同时也要考虑工程上的意外调用:

  • 用户可以通过 DevTools 手动触发函数
  • 业务组件可能被复用,绕过按钮直接调用提交 handler

因此最佳实践是:

  1. UI 层(按钮)禁用/隐藏
  2. 提交 handler 内再做一次 useCan 兜底

本项目在运营管理试点里就是这样做的。


6. 路由与菜单:为什么暂时仍用“角色数组”

你可能会问:既然按钮都用权限点了,为什么路由还在用 handle.permissions: ['ops','ops-viewer']?

原因是工程上的“渐进式落地”:

  • 路由准入通常是粗粒度模块边界,角色数组已经足够表达
  • 在后端不配合的阶段,先保证能快速落地、风险可控
  • 后续若后端能下发 permissions,可以再统一迁移为“路由也用权限点”

现阶段建议的分工:

  • 路由:角色数组(快速控模块)
  • 按钮:权限点(细粒度控动作)

未来演进方向:

  • 路由的 handle.permissions 由 roles 迁移为 permission codes
  • AuthRoute 的校验逻辑从 “roles includes” 升级为 “can(permission)”

7. 运营管理试点:只读角色 ops-viewer 如何落地

企业常见需求:新增 ops-viewer,语义是“只读不可修改”。

在 RBAC 中这类角色非常适合用通配表达:

  • ops-viewer:operations.*.read
  • ops:operations.*

对应的体验结果(示意):

  • 能进入运营管理模块(路由准入)
  • 能看到客户列表与详情(读权限)
  • “新增/编辑/充值写入/并发修改/定价写操作”等按钮变灰并提示“只读无权限”

这套策略的维护成本很低:后续你新增更多运营域页面,只要权限点命名遵守规范,operations.*.read 就能自动覆盖“读能力”。


8. 测试与调试:如何快速验证权限逻辑

8.1 最推荐:改 rbac.json 验证按钮权限

因为 rbac.json 是运行时加载的,你可以直接修改它来验证某个角色的行为:

  • 让 ops-viewer 临时可写:把 operations.*.read 改成 operations.*
  • 精准开放某个动作:加一个 operations.customers.update

8.2 后端不方便配合时:强制覆盖 roles(仅本地调试)

如果你想不依赖后端返回的角色,直接用常量 roles 验证逻辑,可以在 /v1/oauth/me 的解析处覆盖:

  • src/api/auth.ts 的 getMe() 中强制 roles = ['ops-viewer']

或在路由守卫写入 userAtom 前覆盖:

  • src/router/AuthRoute.tsx 内 setUser({ ...me, roles: ['ops-viewer'] })

上述做法仅用于本地验证,不建议提交到主分支。


9. 常见坑与治理建议

9.1 “前端权限不是安全边界”

前端能做的只是:

  • 降低误操作
  • 提供一致体验
  • 避免用户看到无意义入口

但无法阻止抓包直接调用写接口。企业级系统最终一定要后端鉴权。

9.2 权限点必须可治理

当权限点数量增长,务必建立:

  • 命名规范
  • 归属模块(domain)
  • 权限点字典(可选:生成文档或配置清单)
  • 新增权限点的评审流程(至少在 PR 里可见)

否则权限点会像“散落的字符串常量”一样失控。

9.3 “隐藏还是禁用”

企业常用策略:

  • 高风险动作(删除/导出)倾向于隐藏(减少误触与信息泄露)
  • 日常写动作(编辑/提交)倾向于禁用并提示原因(减少困惑)

本项目 AuthButton 同时支持 hide/disable,由业务选择。


10. 如何从前端-only 平滑演进到后端统一权限(路线图)

当后端允许改造时,推荐的升级路线是:

  1. 后端在 /me 里下发 permissions: string[]
  2. 前端 useCan() 的数据源从“roles->permissions 映射”切换为“后端下发 permissions”
  3. rbac.json 转为兜底/降级策略(例如后端异常时保底只读)
  4. 路由准入从 “roles includes” 迁移到 “permission codes”
  5. 进一步引入数据范围(ABAC / scope)处理“只能看自己部门客户”等条件权限

这套演进的关键是:前端接入点(useCan/AuthButton)保持不变,只替换“权限数据从哪来”。


11. 代码(Vue/React 可复制版本)

你会在后续看到本目录下新增:

  • react:一个最小可运行的 React 示例(含 rbac.json、useCan、AuthButton、路由守卫)
  • vue:一个最小可运行的 Vue 示例(含 composable、指令/组件、路由守卫)

目标是:复制目录即可在任意项目里落地同样的模式。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐