Skip to content

权限 RBAC ​

Castor 使用基于角色的权限控制:用户拥有角色,角色被授予菜单和按钮权限。菜单同时决定侧边栏显示、前端路由和后端接口的访问权限。

数据结构 ​

表说明
admin_users后台用户
roles角色
menus菜单与按钮权限,parent_id 自引用形成树
user_roles用户 ↔ 角色,多对多(复合主键)
role_menus角色 ↔ 菜单,多对多(复合主键)
departments部门,parent_id 自引用形成树;用户通过 admin_users.dept_id 归属部门
role_depts角色 ↔ 部门,数据范围为「自定义部门」时使用

menus 表的 menu_type 区分两类记录:

menu_type含义是否显示在导航中
menu页面菜单或分组是(is_visible 为真时)
button按钮权限,挂在页面菜单下否

菜单记录的主要字段:id、name、code、icon、path、component、parent_id、sort_order、menu_type、is_visible、is_active。其中 path 是浏览器地址,component 决定加载哪个前端页面(见 前端开发)。

权限编码 ​

类型格式示例
菜单权限<domain>_<resource>system_users
新增按钮<domain>_<resource>_addsystem_users_add
编辑按钮<domain>_<resource>_editsystem_users_edit
删除按钮<domain>_<resource>_deletesystem_users_delete
导出按钮<domain>_<resource>_exportsystem_users_export
导入按钮<domain>_<resource>_importsystem_users_import

域前缀:admin 域为 system_,component_center 域为 cc_。标准列表页应当具备以上五个按钮权限。

组件示例中心的页面编码为 cc_<分组>_<页面>(如 cc_patterns_kanban),系统管理的页面为 system_<页面>;pnpm scaffold 生成的模块使用它输出的权限前缀(<域前缀>_<name>)。

多个页面共用一个后端模块时,权限属于这些页面所在的目录,而不是某一个页面。组件示例中心的页面模板都使用共享的演示接口(见 组件示例),所以按钮 cc_patterns_add / _edit / _delete / _export / _import 挂在“页面模板”目录(cc_patterns)下,读取接口接受目录编码或其下任一页面的编码(hasAnyMenuPermission(request, ...DEMO_RECORD_VIEW_CODES))。之所以要列出每个页面的编码,是因为只授权了单个页面的角色只保存该页面的编码,不含上级目录。在目录下新增页面时,把它的编码加进这个列表。

权限在哪里生效 ​

位置机制
后端接口routes 中调用 await hasMenuPermission(request, code),不满足返回 403。见 后端开发
侧边栏与路由前端通过 GET /api/admin/my-menus 获取当前用户的菜单树,只为其中的页面菜单生成路由
前端按钮useAuth() 提供 menuCodes 和 hasPermission(code),可用于按权限隐藏按钮

后端检查是真正的安全边界,前端隐藏按钮只是体验优化。

超级管理员 ​

code = 'super_admin' 的角色拥有全部权限:

  • 后端的 hasMenuPermission 对它直接放行。唯一的例外是用 API Token 发起的请求:先检查 Token 自己勾选的权限,所以超级管理员创建的 Token 也只有勾选的那些权限(见 开放接口)。
  • seed-rbac 每次运行都会把全部菜单授予它。

例外:GET /api/admin/my-menus 不做超级管理员短路,而是按角色实际被授予的菜单返回。因为 seed-rbac 会把全部菜单授予超级管理员,正常情况下两者一致。

防止把自己锁在外面 ​

为避免误操作导致没人能管理系统,后端做了这些限制(界面上同步禁用):

  • 「超级管理员」角色不能删除,编码不能改;数据范围固定为「全部数据」,菜单权限固定为全部,只有名称和描述可以修改
  • 只有超级管理员能给别人授予或移除超级管理员角色,也只有超级管理员能编辑、停用、删除超级管理员账号(否则有「编辑用户」权限的人可以改超级管理员的密码)
  • 不能移除自己的超级管理员角色,也不能停用或删除自己
  • 最后一个启用中的超级管理员不能被停用、删除或移除角色;导入用户时同样检查

真遇到超级管理员角色或 admin 账号出问题,运行 pnpm seed:rbac -- --incremental(Docker 部署时重启容器即可):它会重建 super_admin 角色、重新授予全部菜单,并把 admin 账号重新挂到超级管理员角色上。它不会恢复其他账号的角色和启用状态,也不动密码;需要把 admin 的密码重置为 ADMIN_PASSWORD 时加上 --reset-admin-password(如果库里 admin 的密码哈希是无法校验的格式,也会自动重置)。

菜单的唯一事实源:seed-rbac.ts ​

所有菜单和按钮权限都定义在 apps/api/scripts/seed-rbac.ts 的 MENUS_DATA 中。新增或修改菜单时改这个文件,然后同步到数据库。

添加菜单 ​

以在“系统管理 → 组织权限”下添加“客户管理”为例(ID 仅为示意,实际取值见下文“菜单 ID 分配”):

ts
// Page menu
{ id: 2001, name: "客户管理", code: "system_customer", icon: "Users", path: "/system/customers", component: "admin/customer", parent_id: 201, sort_order: 10, menu_type: "menu", is_visible: true, is_active: true },
// Button permissions: id = menu id × 10 + index
{ id: 20011, name: "新增客户", code: "system_customer_add", icon: null, path: null, component: null, parent_id: 2001, sort_order: 1, menu_type: "button", is_visible: false, is_active: true },
{ id: 20012, name: "编辑客户", code: "system_customer_edit", icon: null, path: null, component: null, parent_id: 2001, sort_order: 2, menu_type: "button", is_visible: false, is_active: true },
{ id: 20013, name: "删除客户", code: "system_customer_delete", icon: null, path: null, component: null, parent_id: 2001, sort_order: 3, menu_type: "button", is_visible: false, is_active: true },
{ id: 20014, name: "导出客户", code: "system_customer_export", icon: null, path: null, component: null, parent_id: 2001, sort_order: 4, menu_type: "button", is_visible: false, is_active: true },
{ id: 20015, name: "导入客户", code: "system_customer_import", icon: null, path: null, component: null, parent_id: 2001, sort_order: 5, menu_type: "button", is_visible: false, is_active: true },
  • component 使用 pnpm scaffold 输出的 Menu component 值。
  • icon 沿用 apps/web/src/lib/menu-icons.ts 映射表里已有的名字。
  • 新菜单还需要在 apps/web/src/locales/menus/en-US.json 和 ja-JP.json 中按 code 添加译名,见 多语言。

同步到数据库 ​

bash
pnpm seed:rbac -- --incremental

--incremental 的行为:

  • 按 code 匹配:已存在的菜单只更新字段(ID 不变),不存在的按指定 ID 插入;该 ID 已被其他菜单占用时改用序列的下一个值
  • 只新增和更新,不删除任何已有记录
  • 把全部菜单授予超级管理员
  • 插入后同步 menus 表的 ID 序列,避免后续新增撞主键
  • admin 账号不存在时才创建,并确保它拥有 super_admin 角色

删除菜单需要手动执行 SQL,例如 DELETE FROM menus WHERE id = <id>。

全量重建

不带 --incremental 的 pnpm seed:rbac 会清空 user_roles、role_menus、admin_users、roles、menus 后重新写入,只用于空库初始化。

部署时自动同步

Docker 部署时,容器每次启动都会执行 setup-once,其中包含增量 RBAC 同步,所以新菜单随代码更新自动生效,不会清掉已有用户和角色。

菜单 ID 分配 ​

菜单 ID 在 MENUS_DATA 中写死,role_menus 通过 ID 引用菜单,所以已有 ID 不能重排。

范围ID 区间
系统管理的分组(parent_id=2)201–209
系统管理的页面(parent_id 为所在分组,如 201)21–39;新页面从 2001 开始
组件示例中心(parent_id=3)40–499
页面模板(ID 43)的目录按钮431–435
页面模板下的页面(parent_id=43)4301–4399(43 × 100 + 序号:目录自己有按钮,页面不能再用 431–439)
组件(ID 47)下的页面(parent_id=47)4701–4799(47 × 100 + 序号,与页面模板相同);没有按钮
数据可视化(parent_id=41)411–419
空闲(原“管理系统”和 3D / 创意分组已移除)40、401–409;42、421–429
AI 应用(parent_id=44)441–449
编辑器 / 低代码(parent_id=45)451–459
工程 / 工具类(parent_id=46)461–469
新业务域从 1000 开始
按钮权限菜单 ID × 10 + 序号(如 21 → 211…215)

取 ID 前先查实际占用,不要按“区间里的下一个数”推算:

bash
grep -oE "id: [0-9]+" apps/api/scripts/seed-rbac.ts | awk '{print $2}' | sort -n | uniq

在界面上管理 ​

系统管理下的四个页面对应 RBAC 数据:

页面作用
用户管理创建用户、分配角色,维护昵称 / 邮箱 / 手机 / 头像,启用或停用账号
角色权限创建角色、为角色勾选菜单和按钮权限、设置数据范围
部门管理维护部门树(上级、负责人、排序、状态),供用户归属与数据权限使用
菜单管理查看和调整菜单树

TIP

在界面上新增或修改的菜单不会写回 seed-rbac.ts。另外,增量同步会按 MENUS_DATA 更新同 code 菜单的字段,所以对已定义菜单在界面上做的修改,会在下次同步(包括容器重启)时被覆盖。需要长期保留、随代码部署的菜单,应当写进 MENUS_DATA。

停用账号 ​

用户管理里的「停用账号」需要按钮权限 system_users_status(编辑权限不包含它)。停用后:

  • 该账号输入正确密码也无法登录,返回 403「账号已停用,请联系管理员」,并记一条失败的登录日志
  • 已经登录的会话立即失效(下一次请求返回 401,前端跳回登录页)
  • 不能停用自己,也不能停用或删除最后一个启用中的超级管理员;导入时填了「状态」列同样受这些限制

数据权限 ​

菜单和按钮权限决定「能用哪些功能」,数据权限决定「能看到哪些数据」。它由角色的数据范围控制:

数据范围能看到的数据
全部数据(all,默认)不限制
本部门及下级(dept_and_children)用户所在部门及其所有下级部门的数据
本部门(dept)用户所在部门的数据
仅本人(self)用户自己创建的数据
自定义部门(custom)在角色上勾选的部门的数据
  • 用户有多个角色时取各角色范围的并集;超级管理员和任一角色为「全部数据」时不受限制
  • 范围受限但算出来为空时(例如「本部门」角色的用户没有部门),什么也看不到,不会退化成看全部
  • 超出范围的记录在详情、修改、删除时一律返回 404,不透露数据是否存在;导出同样只导出范围内的数据
  • 停用的部门仍算在「本部门及下级」的范围内;部门树本身不做数据权限
  • 角色的导入模板和导出文件带「数据范围」(填名称或编码均可)和「部门编码」(自定义部门,逗号分隔)两列

想快速体验:运行 pnpm seed:demo,它会建一棵示例部门树、两个角色(部门主管:本部门及下级;普通员工:仅本人)和 6 个示例用户(密码默认 demo123456)。用 zhang.wei 登录只能看到研发部及其下级的人,用 li.na 登录只能看到自己。

哪些数据受控 ​

  • 用户管理:按用户所在部门过滤,「仅本人」即只能看到自己。范围受限的管理员只能把用户分配到自己范围内的部门
  • 用 --data-scope 生成的模块:表上有 dept_id(所属部门)和 created_by(创建人),新建时自动写入当前用户及其部门
bash
pnpm scaffold -- --name contract --domain admin --fields "title:str,amount:float" --data-scope

在自己的模块里接入 ​

数据范围在 routes 里解析、在 repository 里过滤,repository 不接触 request:

ts
// routes.ts
import { currentActor, resolveDataScope } from '@/common/data-scope'
const scope = await resolveDataScope(request)          // 按请求缓存
return service.listItems(page, per_page, search, scope)

// repository.ts
import { dataScopeWhere } from '@/common/data-scope'
const where = and(this.searchWhere(search), dataScopeWhere(scope, { deptColumn: t.dept_id, ownerColumn: t.created_by }))

在模块的 schema.ts 里声明 export const DATA_SCOPE = { deptColumn: 'dept_id', ownerColumn: 'created_by' },pnpm verify 的 data_scope_filter 检查会确认 repository 用了 dataScopeWhere。

Released under the MIT License.