TTeamDesk 学习手册
本章目录 展开导航 ↓

09 · 角色与授权:登录之后还要检查什么#

本章用一个具体问题串起授权:小陈已经登录,也知道某张工单的 UUID,是否就能把它关闭?答案取决于他的团队资格、在该团队的角色、工单归属和业务状态,不能只判断登录成功。

1. 先把产品规则写成权限表#

当前第二阶段核心规则如下,准确范围以现有服务方法为准:

操作 管理员 ADMIN 客服 AGENT 客户 CUSTOMER
创建自己的新团队 可以 可以 可以
创建所在团队的工单 可以 可以 可以
读取所在团队工单 全部 全部 仅本人创建
修改所在团队工单状态 可以,仍要遵守状态规则 可以,仍要遵守状态规则 不可以
读取所在团队成员 可以 可以 可以
添加、改角色、移除成员 可以 不可以 不可以

“所在团队”是每次查询都要验证的条件。ADMIN 不是全站超级管理员,表格中的“全部”也只指该团队。

当前成员列表对团队成员开放,返回姓名、邮箱和角色。不要把“客户只能看自己的工单”扩大解释成“客户也看不到其他成员资料”;这是两种资源的不同产品约定。如果要调整成员资料可见性,应同时改服务字段、界面和测试。

2. 一次业务调用要经过哪些检查#

沿 router.ts、trpc.ts、access.ts 阅读:

text
Cookie 会话有效吗?         → HTTP 上下文得到可信 userId
是否有登录身份?           → protectedProcedure
输入 teamId、id 合法吗?    → Zod schema
是否属于这个团队?         → requireMembership
角色是否允许这个动作?     → requireAdmin 或具体服务规则
目标资源是否在允许范围内? → teamId、creatorId 等查询条件
业务事实是否允许修改?     → 状态机、version 等

这是职责地图,具体执行顺序跟接口有关。例如状态修改先判断客户角色,因此客户请求修改他人工单可能先得到 FORBIDDEN,而不是 NOT_FOUND。不要只靠一张统一错误码表猜测全部调用顺序。

3. 成员检查是在数据库确认一条事实#

核心代码很短:

ts
// 源码节选: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 会误伤客服。

路由把团队、用户和数据库传给服务:

ts
// 源码节选: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 验证会话和输入 → 服务查询团队成员与角色 → 限定目标资源并检查业务规则 → 数据库操作 → 返回数据或明确错误。 下一章看一次成员变更为什么需要事务与锁。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。