权限 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>_add | system_users_add |
| 编辑按钮 | <domain>_<resource>_edit | system_users_edit |
| 删除按钮 | <domain>_<resource>_delete | system_users_delete |
| 导出按钮 | <domain>_<resource>_export | system_users_export |
| 导入按钮 | <domain>_<resource>_import | system_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 分配”):
// 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添加译名,见 多语言。
同步到数据库
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 前先查实际占用,不要按“区间里的下一个数”推算:
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(创建人),新建时自动写入当前用户及其部门
pnpm scaffold -- --name contract --domain admin --fields "title:str,amount:float" --data-scope在自己的模块里接入
数据范围在 routes 里解析、在 repository 里过滤,repository 不接触 request:
// 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。
