09 · 角色与授权:登录之后还要检查什么#
本章用一个具体问题串起授权:小陈已经登录,也知道某张工单的 UUID,是否就能把它关闭?答案取决于他的团队资格、在该团队的角色、工单归属和业务状态,不能只判断登录成功。
1. 先把产品规则写成权限表#
当前第二阶段核心规则如下,准确范围以现有服务方法为准:
| 操作 | 管理员 ADMIN | 客服 AGENT | 客户 CUSTOMER |
|---|---|---|---|
| 创建自己的新团队 | 可以 | 可以 | 可以 |
| 创建所在团队的工单 | 可以 | 可以 | 可以 |
| 读取所在团队工单 | 全部 | 全部 | 仅本人创建 |
| 修改所在团队工单状态 | 可以,仍要遵守状态规则 | 可以,仍要遵守状态规则 | 不可以 |
| 读取所在团队成员 | 可以 | 可以 | 可以 |
| 添加、改角色、移除成员 | 可以 | 不可以 | 不可以 |
“所在团队”是每次查询都要验证的条件。ADMIN 不是全站超级管理员,表格中的“全部”也只指该团队。
当前成员列表对团队成员开放,返回姓名、邮箱和角色。不要把“客户只能看自己的工单”扩大解释成“客户也看不到其他成员资料”;这是两种资源的不同产品约定。如果要调整成员资料可见性,应同时改服务字段、界面和测试。
2. 一次业务调用要经过哪些检查#
沿 router.ts、trpc.ts、access.ts 阅读:
Cookie 会话有效吗? → HTTP 上下文得到可信 userId
是否有登录身份? → protectedProcedure
输入 teamId、id 合法吗? → Zod schema
是否属于这个团队? → requireMembership
角色是否允许这个动作? → requireAdmin 或具体服务规则
目标资源是否在允许范围内? → teamId、creatorId 等查询条件
业务事实是否允许修改? → 状态机、version 等这是职责地图,具体执行顺序跟接口有关。例如状态修改先判断客户角色,因此客户请求修改他人工单可能先得到 FORBIDDEN,而不是 NOT_FOUND。不要只靠一张统一错误码表猜测全部调用顺序。
3. 成员检查是在数据库确认一条事实#
核心代码很短:
// 源码节选:src/server/services/access.ts
const member = await db.membership.findUnique({
where: { teamId_userId: { teamId, userId } },
});
if (!member)
throw new TRPCError({
code: 'FORBIDDEN',
message: '你不是该团队成员,或成员资格已被移除',
});
return member;teamId 表示客户端想访问哪个团队,userId 表示服务端已验证的调用人。把两者组合查询,才得到“这个人目前在这个团队里的资格”。
角色也从这条记录读取,没有相信客户端表单里的 role: ADMIN。即使前端缓存还显示管理员,服务端也会以查询到的角色为准。
当前实现不把成员角色永久缓存进登录会话中。所以被移除后,即使 Cookie 仍能证明用户身份,后续业务请求也会因为缺少 Membership 被拒绝。会话撤销与团队移除分别解决不同问题。
4. 管成员与改工单使用不同规则#
requireAdmin 先调用 requireMembership,再要求 member.role === 'ADMIN'。成员管理服务使用它,因为添加和撤销权限属于管理员动作。
工单 updateStatus 则在确认成员之后拒绝 CUSTOMER;管理员与客服都可继续。这不是“检查越严格越好”,而是权限要匹配产品规则。把所有接口都套 requireAdmin 会误伤客服。
路由把团队、用户和数据库传给服务:
// 源码节选:src/server/api/router.ts 的工单状态接口
createTicketService(ctx.db, ctx.userId, input.teamId).updateStatus(input);.input(...) 负责类型、长度和枚举形状;服务负责资格、资源与业务事实。protectedProcedure 并不会自动为所有 Prisma 查询附加团队过滤。
拓展阅读RBAC 与资源归属如何协作?
按管理员、客服、客户分配能力,是基于角色的访问控制思路。客户读取工单还要比较创建者,这又依赖资源属性。真实业务往往组合使用这些条件;仅知道 role === CUSTOMER,还不足以判断某张工单能不能读。
5. 为什么页面隐藏按钮仍然值得做#
Members 对管理员显示添加表单、角色 Select 和移除按钮;其他角色显示只读角色标签。工单详情对客户隐藏状态操作。
这能减少误操作和困惑,但请求可以从脚本发出,浏览器中的角色状态也可以被改写。因此两个层次都要保留:
- 页面依据已获取角色安排交互。
- 服务端依据真实成员关系决定是否执行。
不要为了演示“隐藏按钮不是权限”就在项目里留下临时绕过入口。已有集成测试可直接构造客户调用,验证接口依然拒绝。
6. 为什么有时是未登录,有时是无权限,有时是未找到#
| 场景 | 当前典型结果 | 失败边界 |
|---|---|---|
| 无有效会话调用业务接口 | UNAUTHORIZED |
没有可信身份 |
| 已登录但不属于输入团队 | FORBIDDEN |
缺少成员关系 |
| 客户修改工单状态 | FORBIDDEN |
角色不允许 |
| 合法团队成员查询其他团队的工单 ID | NOT_FOUND |
资源不在查询范围 |
| 客户读取本团队别人创建的工单 | NOT_FOUND |
不满足创建者条件 |
| 唯一管理员尝试降级自己 | BAD_REQUEST |
会破坏团队业务规则 |
| 旧版本请求修改状态 | CONFLICT |
编辑依据过期 |
将“查不到”和“不在资源可见范围”统一处理,可以减少暴露其他团队工单存在性的机会。与此同时,成员资格检查仍可能先返回无权限,这并不矛盾。
7. 权限变更何时生效,边界是什么#
测试文件里的“下次请求生效”指:权限变更提交后,新请求在查询成员关系时读到新的资格。它不意味着能远程抹除用户已经读到、截图或缓存的数据,也不意味着撤销了所有已经开始执行的请求。
当前读取方法在授权检查后执行查询,并没有把每一次读取都放进与成员变更共用的团队锁。因此已经通过检查的在途读取,不能夸大成会在撤权瞬间被取消。写操作中的锁与事务见下一章。
前端缓存里的旧按钮短暂残留时,服务端拒绝后应给出提示并刷新相关数据;不能因为按钮仍然可见,就判断服务端权限失效。
8. 本章练习#
同一个用户在甲是 ADMIN、乙是 CUSTOMER。为什么调用乙的 team.changeRole 把自己升为 ADMIN 会失败?
动手练习查看参考答案
参考答案:ctx.userId 虽然真实,但 requireAdmin(tx, userId, 乙团队ID) 查到的是乙的 CUSTOMER 成员关系。它不会查询“此人在任意团队是否做过管理员”。请求里的目标角色只是管理员准备设置的结果,不能充当调用人的权限证明。
复述链路:前端选择动作 → tRPC 验证会话和输入 → 服务查询团队成员与角色 → 限定目标资源并检查业务规则 → 数据库操作 → 返回数据或明确错误。 下一章看一次成员变更为什么需要事务与锁。