正确做法是每个角色只存 parent_id 字段指向直接上级,用户文档只存 roleIds 数组;鉴权时用 $graphLookup 从用户角色沿 parent_id 向上聚合,配合 roles.parent_id_1 等索引及 explain 验证执行计划,确保权限实时、一致、可审计。

角色继承关系不能用嵌套数组存
用 children 字段在角色文档里存子角色 ID 列表,看着直观,但增删角色时要双向更新(父删子、子改父),极易数据不一致。更麻烦的是聚合时没法用 $graphLookup 从子往父查——它只支持从起点按字段“往下”遍历。
正确做法是每个角色只存一个 parent_id 字段,指向直接上级。这样新增角色只需写一次,删除也只需清空自己的 parent_id 或设为 null。查权限时用 $graphLookup 从用户直连角色出发,顺着 parent_id 往上爬最多 5 层,就能拿到完整继承链。
-
$graphLookup的connectFromField必须设为"parent_id",connectToField设为"_id" - 别把
maxDepth设太大,超过 5 层的继承链基本说明模型设计过深,该重构了 - 确保
roles.parent_id_1有索引,否则$graphLookup会全表扫
用户文档里只存 roleIds 数组,别存权限快照
有人图省事,在用户文档里直接存展开后的权限列表,比如 permissions: ["post:read", "post:delete:own"]。这会导致权限变更后必须批量更新所有关联用户,延迟高、易漏、难审计。
应该只存 roleIds: [ObjectId("..."), ObjectId("...")],每次鉴权都实时聚合计算。虽然多一次查库,但换来的是强一致性、零缓存失效风险、权限变更秒级生效。
- 聚合里用
$lookup关联roles集合时,localField必须是数组,否则匹配不到多个角色 - 权限字段统一用字符串数组,如
["post:read", "post:delete:own"],别用嵌套对象或布尔值,方便后续用$setUnion合并 - 生产环境校验权限别用
$in,改用$expr: { $in: ["post:delete", "$allPerms"] },语义更准且能走索引
聚合鉴权必须加索引提示和执行计划验证
一个看起来跑得通的聚合,在百万级用户表上可能耗时 2s+,只因没走索引。MongoDB 不会自动为聚合中的 $lookup 或 $graphLookup 推荐索引,得手动干预。
上线前必须对聚合加 .explain("executionStats"),重点看 executionStages 里有没有 IXSCAN,以及 nReturned 是否接近输入文档数。如果看到 COLLSCAN,说明某步没走索引。
- 关键索引至少要有:
users.roleIds_1、roles._id_1、roles.parent_id_1 -
$unwind后再$group的阶段,如果结果集大,考虑加allowDiskUse: true防内存溢出 - 别信本地开发环境的性能——集合数据量、内存配额、WiredTiger 缓存大小都和生产不同
业务权限不能靠 MongoDB 内置角色兜底
readWrite、dbAdmin 这类内置角色只管“能不能执行 deleteOne”,不管“能不能删 ownerId: "user_123" 的文档”。硬套它们等于把业务规则交给数据库引擎猜,结果不是过度授权就是权限真空。
真正要控制“只删自己订单”,必须在应用层做两件事:一是在查询条件里显式加 { ownerId: req.user.id };二是在写操作前用聚合鉴权确认用户真有 order:delete:own 权限。两者缺一不可。
- 光靠鉴权不加查询过滤,攻击者可绕过中间件直连 DB 执行恶意命令
- 光加过滤不鉴权,API 逻辑一旦漏判,就变成“谁都能删自己订单”,但无法阻止删别人订单
- 权限字符串命名要有层级感,比如
"order:delete:own"和"order:delete:any",避免用模糊词如"manage"
explain、没把业务规则拆成“鉴权 + 查询约束”两步落进代码。


















