11 · 数据隔离:为什么每个查询都要带上团队边界#
登录验证了身份,成员关系确认了角色,现在还差一步:把这些事实变成数据库查询条件。本章沿创建、列表、详情、更新四条工单路径检查隔离,再看看团队切换与退出后的前端状态。
1. 本项目采用什么隔离方式#
当前各团队共用 PostgreSQL 中的业务表,通过 teamId 区分数据,服务层在访问时加团队与归属条件。这是共享表的租户隔离方式。
没有为每个团队创建独立数据库,也没有在当前迁移中配置 PostgreSQL Row-Level Security。不能把“已经有 teamId 列”理解为“数据库会自动为所有查询隔离团队”。漏写过滤条件的后果是真实的,因此接口、服务和测试都要围绕这条边界设计。
把它类比成前端列表筛选只能帮助理解集合范围:权限过滤必须发生在服务端取数时,不能先把全部团队的数据发到浏览器再 filter()。
2. 创建时同时确定归属与创建者#
在 工单服务 create 找到:
// 源码节选:前面已经在事务中检查当前团队成员关系
const ticket = await tx.ticket.create({
data: {
...input,
teamId,
creatorId: userId,
dueAt: defaultDueAt(input.priority),
},
select: ticketSelect,
});内容来自校验后的表单,团队来自请求目标并通过成员检查,创建者来自会话。dueAt 是后续提醒功能,不是隔离条件。
当前创建流程与成员变更共用团队锁,在锁内确认用户没有被移除再创建。这避免了“先确认成员、等待期间被移出、仍继续写工单”的写入竞争。
不要允许客户端用 creatorId 决定自己代表谁。客服代客创建如果将来需要,应设计明确的代理规则与记录,不能顺手放开一个字段。
3. 列表与总数使用同一份权限条件#
列表过滤器 的核心是:
// 源码节选:这里只保留租户与创建者边界,省略其他筛选
return {
teamId,
...(scopeCreatorId || member.role === 'CUSTOMER'
? { creatorId: scopeCreatorId ?? member.userId }
: {}),
};普通工单列表不传 scopeCreatorId,客户因此被固定限制为本人创建;管理员与客服则读取本团队。状态、优先级、搜索和负责人筛选只会继续缩小这个集合,不能移除外层团队条件。
scopeCreatorId 供后续导出固定创建者范围等场景使用。本阶段先理解:业务提供的可信范围与用户输入的筛选不是同一级权限来源,不能让浏览器随意传一个创建者来扩大可见范围。
服务的 findMany 和 count 共用同一个 where。如果只过滤行数据、总数却数了全站,不仅分页错误,还可能暴露其他团队的数据规模。
4. 详情不能只按工单 ID 查询#
详情使用:
// 源码节选:src/server/services/ticket.ts 的 detail
const ticket = await db.ticket.findFirst({
where: {
id,
teamId,
...(member.role === 'CUSTOMER' ? { creatorId: userId } : {}),
},
select: ticketSelect,
});这里不用“先按 ID 取到全记录,再交给前端判断是否能看”。findFirst 接受当前范围内的组合条件;没匹配到就按未找到返回。
requireTicketAccess 也封装了成员与资源归属检查,供回复、记录等相关服务复用;当前工单详情服务自身写了同样的过滤逻辑。不要把“有一个公共 helper”写成“所有资源访问自动通过它”。阅读和审查时必须逐个确认调用路径。
UUID 难猜不代表可访问。别人把完整详情链接发给你,也不会替你创建 Membership 或改变工单创建者。
5. 更新的最终条件也必须限定团队#
状态修改不仅前置查询带 teamId,最终条件更新同样带它:
// 源码节选:省略提醒等更新字段
await tx.ticket.updateManyAndReturn({
where: { id: input.id, teamId, version: input.version },
data: { status: input.status, version: { increment: 1 } },
select: ticketSelect,
});三个条件分别回答:是哪张工单、属于哪个团队、是不是用户看到的版本。版本冲突保护不能替代权限;权限通过也不能替代状态机检查。
客户在更前面的角色检查就被拒绝,管理员与客服通过角色检查后,也只能修改目标团队内的工单。不能拿自己在甲的管理员资格去关闭乙的工单。
拓展阅读索引与复合外键怎样帮助隔离?
[teamId, status, createdAt] 等索引帮助常见团队查询;后续回复与工单使用 [ticketId, teamId] 复合外键,能阻止关系上挂错团队。这些机制补强查询效率和关联一致性,但不会替每个请求推导用户权限。当前共享表方案仍要求服务显式过滤;RLS 等更深入的防线可在后续需求下评估。
6. URL 中的 team 参数只选择上下文#
TeamWorkspace 从 team URL 参数读取所选团队,再从服务返回的“我的团队”中查找它。没有显式选择时取用户团队列表第一项;显式选择了无权团队时,不会把它当成有效 current。
切换团队会导航到 /?team=<ID>。当前工作区按下列组合设置 React key:
// 源码节选:团队上下文 Provider
key={`${session.data.user.id}:${current.id}:${current.role}`}用户、团队或角色变化时,子树重新挂载,减少上一个上下文中的局部 state 残留。useTeam() 让组件读到当前团队,不是授权令牌;下一次接口调用仍在服务端检查成员关系。
查询输入也包含 teamId,所以 TanStack Query 能区分甲列表与乙列表。不能只改页面标题,却用不含团队输入的同一份缓存展示工单。
7. 为什么退出时同时清理查询与草稿#
当前退出流程大致是:
- 显示退出中状态,并取消正在等待的 Query 查询。
- 调用
authClient.signOut()撤销当前认证会话。 queryClient.clear()清理客户端查询与变更缓存。- 删除当前标签页中该账号的 sessionStorage 草稿副本。
- 完整导航回首页,销毁已渲染的私有页面状态。
取消查询不是数据库回滚,也不能保证撤销已经到达服务端的业务写入。这里的目的主要是减少旧请求与旧缓存继续影响当前页面。
草稿是第三阶段加入的功能:当前 drafts.ts 用用户 ID 区分存储 key,用团队与工单区分条目。它用 sessionStorage,和教程阅读站用 localStorage 保存学习进度是两套不同机制。
sessionStorage 按页面会话区分,当前退出代码没有遍历其他标签页的草稿存储。不要把“清除本页账号草稿”扩大为“全浏览器所有标签页的本地副本被远程删除”;其他页面能否继续访问业务,仍由会话与资源检查决定。
缓存隔离保护共享浏览器里的使用体验,服务端授权保护数据访问资格。任何一方都不能替代另一方;清理前端缓存不会让泄露过的数据从别人的记忆或截图里消失。
8. 一个请求从哪一层失败#
| 请求 | 成员检查 | 资源检查 | 结果 |
|---|---|---|---|
| 乙的成员用甲 teamId 查甲工单 | 无甲资格 | 不继续 | FORBIDDEN |
| 乙的成员用乙 teamId 查甲工单 ID | 有乙资格 | 工单不属于乙 | NOT_FOUND |
| 甲客户查甲内另一客户工单 | 有甲资格 | 创建者不符 | NOT_FOUND |
| 甲客服改甲工单 | 有甲资格 | 团队匹配 | 继续状态和版本检查 |
练习:为什么“先按 id 更新成功,再检查 teamId”无法补救?
动手练习查看参考答案
参考答案:数据库修改已经发生,事后的拒绝消息不能撤销已提交的写入。当前实现把资格检查放在写入之前,把团队和版本放进最终条件,并让相关修改处于事务中。判断、条件写入和事务要共同围绕同一业务边界设计。
本章链路是:URL 与表单选择目标 → 会话确认调用人 → 成员角色确定可访问集合 → Prisma 每次读写带团队 / 创建者条件 → 返回允许的数据 → 前端按团队区分并清理缓存。