后台权限系统:RBAC、只读访客和接口保护
本文目录
角色名称不是权限实现。可见菜单、禁用按钮和前端路由守卫只改善使用体验,攻击者可以直接请求 API。最终授权必须在服务器每次请求中执行,并在具体资源上判断“谁对哪个对象执行什么操作”。
定义角色和操作矩阵
先列资源:文档、分类、媒体、电子书、评论、配置、用户、审计记录。再列操作:list、read、create、update、publish、delete、restore、purge、upload、download、manage-role。为 ADMIN、EDITOR、VIEWER、USER 分别填写允许/拒绝理由。VIEWER 可以只读预览公开的管理界面,但不应读草稿、隐藏、加密、回收站或未授权文件。
RBAC 适合表达“这个账号拥有哪些一般能力”;对象关系和属性条件还需 ABAC/关系授权,例如作者只能修改自己的草稿、管理员只能管理所在租户。不要把所有业务规则塞进一个庞大角色字符串,也不要为每个用户复制新角色来绕开资源范围。
默认拒绝,逐请求校验
认证只确认身份,授权决定操作是否允许。服务端从签名有效的身份上下文获取 userId/role,再根据请求中的资源 ID 查询并检查 tenant、所有者、发布状态和密级。客户端传来的 role、ownerId、isAdmin 一律不可信。列表、详情、搜索、树、导出、下载、批量操作和 GraphQL/子资源路径都需各自满足同一规则。
async function canReadDocument(actor, documentId, repository) {
if (!actor) return false;
const document = await repository.findVisibleDocument({
id: documentId,
tenantId: actor.tenantId,
});
if (!document) return false;
if (document.visibility === 'PRIVATE') return document.ownerId === actor.userId;
if (document.visibility === 'ADMIN_ONLY') return actor.roles.includes('ADMIN');
return actor.permissions.includes('document:read');
}
示例要求 repository 查询已按 tenant 过滤;生产实现还要处理删除、加密、发布时间、继承权限和资源不存在时的信息泄漏。对敏感资源可让“无权”和“不存在”返回相同 404,但审计日志仍记录内部拒绝原因。
权限变更与会话失效
长寿命 JWT 里的 role 是签发时快照。用户被禁用、降权或移出组织后,旧 token 可能仍有效直到过期。根据风险选择短时 access token + refresh session、用户 tokenVersion/sessionVersion、会话撤销表或在线权限查询。角色/封禁变更后应清理会话或提高版本号;关键操作需服务端重新检查当前权限。
用户角色、租户关系、资源密级和发布状态变化都应记录审计事件。避免在日志里存 token 原文;只保留 actor ID、操作、目标、结果和关联 ID。
VIEWER 和隐藏内容边界
VIEWER API 应只 select 允许的字段,不要把 secret、正文或隐藏标记先返回再让前端过滤。树和列表同样需要范围过滤,否则可以通过父子数量或 slug 推断隐藏内容。下载接口应重新检查当前账户、书籍状态和授权;一次登录不能永久授权未来已下架的书。
前端根据返回的 capability 隐藏或禁用控件,服务端仍作为唯一执行边界。预览模式应使用真实只读身份和脱敏数据,不要把管理员页面只用 CSS 覆盖来假装安全隔离。
把权限策略变成可组合的边界
把“身份是否有效”“角色能否做这类动作”“这个用户能否操作这个对象”分开验证。Guard 适合保护路由级能力,service/policy 适合检查对象、状态和业务条件;只用全局角色 Guard 不能自动防止 IDOR/BOLA。查对象时尽量把租户、所有者和可见状态并入查询条件,减少先加载敏感对象再忘记授权的机会;写操作还需在事务或条件更新里重新约束状态,避免检查后权限发生变化。
async function updateArticle(actor, articleId, patch, db) {
if (!actor?.permissions.includes('article:edit')) return { status: 403 };
const result = await db.article.updateMany({
where: {
id: articleId,
tenantId: actor.tenantId,
OR: [{ ownerId: actor.userId }, { editors: { some: { userId: actor.userId } } }],
status: { in: ['DRAFT', 'REVIEW'] },
},
data: patch,
});
return result.count === 1 ? { status: 200 } : { status: 404 };
}
这里把不可见对象统一折叠为 404,避免暴露 ID 是否存在;产品若要区分 403/404,应先明确枚举风险。patch 仍须经过 DTO 字段白名单,不能让调用方改 tenantId、ownerId、审核状态或权限字段。复杂授权可以做成纯 policy 函数并传入最小化对象,便于单测;除非缓存键包含身份、租户、资源版本且失效可靠,否则不要跨请求缓存策略结果。
Nest Guards 可以读取 handler/class metadata 来执行路由级授权,但它运行在 pipe 之前,不能代替 DTO 校验;资源级授权通常还需要在业务服务读取对象后执行。HTTP、GraphQL、WebSocket 和微服务是不同执行上下文,逐一验证 guard 是否覆盖,不能从 HTTP 测试推断所有 transport 都受保护。
资料:NestJS Guards 解释路由上下文中的授权边界;NestJS Authorization 展示策略化检查;OWASP API1:2023 BOLA 说明基于对象 ID 的端点都需要对象级授权。
资料:NestJS Guards 解释路由上下文中的授权边界;NestJS Authorization 展示策略化检查;OWASP API1:2023 BOLA 说明基于对象 ID 的端点都需要对象级授权。
负向测试矩阵
-
对同一角色使用不同所有者账号替换资源 ID;验证同角色不同所有者不能互相读写。
-
在同 ID 不同 tenant、嵌套子资源、批量请求夹带无权对象、权限撤销与并发写入时验证拒绝和无副作用。
-
每种 HTTP 规则都检查对应 GraphQL、WebSocket 或消息 handler 是否同样授权;没有 guard 的 transport 要显式标出并补 service policy。
-
匿名用户对所有受保护端点均拒绝;角色字段由服务端签发,客户端改包无效。
-
VIEWER 可读被允许的公开/授权内容,但创建、修改、发布、删除、回收站、版本和用户管理均拒绝。
-
用户 A 不能读写用户 B 的对象;跨租户 ID 替换不能越权。
-
草稿、定时发布、加密、已删除、已下架和签名过期资源逐个验证。
-
列表/计数/搜索/导出/文件签名接口使用与详情相同的边界。
-
角色变更或账号禁用后,旧 access/refresh token 在设计的时限内失效。
使用 OWASP Authorization Cheat Sheet 的 deny-by-default 与每请求授权原则。测试不能只验证按钮消失;直接 HTTP 请求才证明业务边界有效。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。