ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

成为全栈·Node 后端篇·权限模型:从认证到 RBAC

2026/9/6 10:25:53 拓冰建站 浏览量
成为全栈·Node 后端篇·权限模型:从认证到 RBAC 成为全栈·Node 后端篇·权限模型从认证到 RBAC登录成功只是故事的开始。假设系统认出了你是用户 7——可然后呢你能改自己写的草稿能不能改别人的你能发文章能不能把别人的文章直接审核发布“你是谁和你能干什么”是两件完全不同的事。前者叫认证Authentication后者叫授权Authorization。前两篇我们刚把认证走通——一篇聊透 JWT 与有状态刷新令牌的方案一篇把注册登录四条链路落地跑通。这一篇补上另一半授权模型我们到底怎么建——为什么选 RBAC 而不是 ACL以及那个把角色阶梯 资源归属收进一行的核心原语guard。授权这块做错往往比认证漏得更隐蔽——不是进不来而是能进不该进的地方。三角色怎么分层、null与非归属为什么必须一个是 404 一个是 403、怎么防止管理员一失手把自己锁在门外——这些正是这一篇要过的关。一、认证和授权是两件事很多人把这两个词混用但它们职责分明认证Authentication你是谁——靠账号密码 / 令牌证明身份。我们 M1-12/13 讲的全是它。授权Authorization你能干什么——认证通过之后系统判断你有没有权限执行这个操作。打个比方认证是刷工牌进大楼授权是你的工牌能不能进机房、能不能动服务器。进了大楼不等于哪儿都能去。后端每个需要权限的接口都得在认证之后再做一次授权判断。二、三角色模型member / editor / admin我们的系统把用户分成三个层级契约第 4 铁律的口径member会员普通用户。能发文章默认进待审、评论、点赞、收藏、改自己资料。editor编辑内容管理者。能审核会员投稿、发布/下架文章、管全站内容。但不管用户、不管角色、不管站点配置——它的职权边界是内容。admin管理员最高权限。在 editor 之上还能管理用户改角色、禁用账号、改站点配置、重置他人密码。注意 editor 和 admin 的边界是关键设计editor 再厉害也碰不到人和配置。一个内容编辑没必要、也不应该能把自己提成 admin 或封禁别人——权限越小出错面越小也越符合最小权限原则。三、P-26guard是授权判断的核心原语每个需要权限的接口之前我们在路由里写的guard(member)/guard(admin)不是装饰而是授权判断的核心原语。看src/middleware/auth.ts里的guard实现constROLE_RANK:RecordRole,number{member:0,editor:1,admin:2};exportconstguard(minRole:Role,resolveOwner?)async(c,next){constuserc.get(user);if(!user)thrownewAppError(ErrCode.TOKEN_MISSING,401,未携带访问令牌);constroleOk(ROLE_RANK[user.role]??-1)(ROLE_RANK[minRole]??99);if(roleOk)returnnext();// ④a 角色阶梯角色够高放行if(resolveOwner){constownerIdawaitresolveOwner(c);if(ownerIdnull)thrownewAppError(ErrCode.NOT_FOUND,404);// 资源不存在 → 404if(ownerIduser.id)returnnext();// ④b 归属者放行ownerOverride}thrownewAppError(ErrCode.FORBIDDEN,403);// 存在但非归属者 → 403};它实现了契约第 4 铁律的两条放行规则逻辑非常清晰④a 角色阶梯你的角色等级≥要求的最低角色直接放行。比如guard(admin)只有 adminrank 2能过editor1和 member0都卡在roleOk false。④b 资源归属ownerOverride如果角色不够但有resolveOwner且当前用户就是资源主人也放行。典型场景会员改自己的草稿——他不是 editor但文章是他写的凭归属权放行。这两条是或的关系角色够或是主人都能过。这个guard原语把角色 归属两种授权语义统一收口路由里一行guard(editor, resolveArticleOwner)就搞定不用每个接口手写一堆if。四、P-27ownerOverride 与 404 正交错误码不能串台guard里有一个极易写错、却至关重要的细节P-27资源不存在和存在但你不归是两种完全不同的应答必须区分清楚。看上面代码当resolveOwner返回null资源查不到抛的是404 NOT_FOUND当资源存在、但ownerId ! user.id不是你的抛的是403 FORBIDDEN。为什么这么较真两个理由第一安全。如果你不是这篇文章的主人也返回 404攻击者就能用遍历 ID 的方式靠返回 404 还是 403来探测哪篇文章存在——这又是一次信息泄露。把不存在统一成 404攻击者无从分辨文章不存在还是文章存在但不是你的探测失效。第二语义正确。404 是资源层面的不存在403 是授权层面的拒绝。前端拿到 404 会跳内容已删除/不存在页拿到 403 会跳你没有权限页——两套 UI 流程完全不同。如果把两者混了前端的错误提示就会张冠李戴。所以guard必须严格守住null→ 404非 null 非归属 → 403二者正交错误码绝不串台。这正呼应契约里x-authz授权求值的机器化——授权规则不是写在注释里的良心而是能被校验的结构化字段。五、角色边界editor 管内容admin 管人回到第二节说的editor 不管人。这在代码里是直接体现的。看src/routes/users-admin.ts——只有重置用户密码这一个 admin 接口usersAdminRoute.post(/:id/reset-password,authMiddleware,guard(admin),// ← 明确只要 admineditor 进不来v.json(resetSchema),async(c){constidNumber(c.req.param(id));awaitresetPassword(id,newPassword);returnok({});},);guard(admin)硬硬地卡住只有 admin 能重置他人密码v1 没有邮件找回这是忘记密码的唯一兜底。editor 哪怕想帮用户重置密码也会被 403 挡下——因为管用户是 admin 的专属领地。而文章审核、发布这类内容操作用的是guard(editor, ...)或guard(admin, ...)editor 就能做。一句话总结边界内容相关用 editor 起用户/配置相关必须 admin。把这条线画清楚系统的权限轮廓就立住了。六、guard 落进真实路由以文章编辑为例光看guard函数定义还是抽象落到真实路由才看得清它怎么干活。看src/routes/articles-write.ts里更新文章的端点articlesWriteRoute.put(/:id,authMiddleware,guard(editor,resolveArticleOwner),// ← 角色阶梯(editor) OR 资源归属(owner)v.json(updateArticleSchema),async(c){constidNumber(c.req.param(id));constexistingawaitgetArticleOr404(id);constupdatedawaitupdateArticleRow(id,input,existing,privileged);returnok(toArticle(updated));},);这里的guard(editor, resolveArticleOwner)把前面说的两条放行规则都用上了如果你是editor 或 admin角色阶梯直接过能改任何文章包括别人的如果你只是member角色不够但resolveArticleOwner会查出这篇文章的author_id——如果是你写的凭 ownerOverride 放行让你改自己的草稿如果你既不是 editor、又不是作者那就是存在但非归属 →403。这就是 RBAC 比 ACL 优雅的地方。**ACL访问控制列表**是给每个资源、每个用户单独配权限用户和资源一多权限矩阵就爆炸而且内容编辑能不能碰用户这种跨资源规则根本没法表达。RBAC 把权限收束到角色这一层再叠加资源归属这一维度刚好映射我们内容 vs 人的边界——一个guard(editor, ...)就声明清楚了。更关键的是这套授权规则不是只活在代码里。契约docs/api/openapi.v1.yaml的第 4 铁律用x-authz字段把每个端点的minRoleownerOverride机器化地记了下来配套的check_contract.py能校验代码里的 guard 和契约里的 x-authz 是否一致。换句话说授权规则成了能被自动化校验的结构化事实而不是散落在各路由、靠人肉 review 才能发现漂移的注释。这也回答了一个常被人问的问题权限配置会不会代码改了、文档忘了改地慢慢漂移在我们的设计里只要x-authz和guard对不上门禁就会红——授权规则是可验证的而不是信仰。七、P-29自我护栏与最后 admin保护授权判断里还有两类防御性护栏专门防管理员把自己玩死。都在src/services/user.ts的updateUser里// 护栏一禁止管理员变更自身角色/状态if(privilegedoperatorIdid){thrownewAppError(ErrCode.FORBIDDEN,403,undefined,{errors:[{field:id,message:不能变更自己的角色或状态}],});}// 护栏二最后 admin 保护if(privilegedexisting.roleadmin){constwouldLoseAdminbody.role!undefinedbody.role!admin;constwouldDisablebody.status!undefinedbody.statusdisabled;if(wouldLoseAdmin||wouldDisable){constactiveAdmins(awaitgetDb().select({count:...}).from(users).where(and(eq(users.role,admin),eq(users.status,active))).all())[0];if(Number(activeAdmins?.count??0)1){thrownewAppError(ErrCode.CONFLICT,409,undefined,{errors:[{field:role,message:至少保留一名活跃 admin}],});}}}自我护栏管理员不能改自己的角色或状态。否则他手一滑把自己从 admin 降成 member、或禁用自己系统就再也没人能提权回来了——典型的把自己锁门外。最后 admin 保护如果你要把唯一的活跃 admin 降级或禁用直接409拒绝。这是防全站无 admin的终极兜底——否则一旦最后的管理员没了连重新造 admin都得走 seedM1-13 那个死锁生产环境可不敢赌这个。这两个护栏是授权系统成熟度的标志它不只判断你能不能还会判断这个操作会不会把系统搞到无法恢复。好的权限设计得防得住手滑。八、P-28 轻提删除守卫引用存在即拒顺带点一个会在后续文章反复用到的守卫原则P-28删除一个资源前要先查还有没有别的东西引用它有引用就拒绝或要求先清理引用。而且查引用时必须isNull(deletedAt)排除已软删的否则会把已删除但还占着坑的脏数据算进去。这条在文章软删M1-15、分类删除M1-16里都会落到实处理这里先记住原则。九、小结与前瞻权限模型是把能干什么变成可执行的规则认证 ≠ 授权认证回答你是谁授权回答你能干什么两道门都要过。三角色member投稿/互动/ editor管内容不管人/ admin含用户与配置。editor 边界是内容相关用 editor人/配置必须 admin。P-26guard(minRole, resolveOwner?)是核心原语——④a 角色阶梯或④b 资源归属两条放行规则统一收口。P-27ownerOverride 与 404 正交——null→404、非 null 非归属→403错误码绝不串台防探测、保语义。P-29自我护栏不能改自己角色/状态 最后 admin 保护活跃 admin≤1 被降/禁→409。P-28删除守卫引用存在即拒查引用排除已软删。下一篇{{LINK:M1-15}}我们进文章 CRUD 与投稿状态机草稿/待审/已发布三态怎么流转、会员投稿为什么默认进待审、admin 发布即审核、以及软删除和 slug 部分唯一的那些坑。如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer