多租户系统的数据隔离,常被简化成“列表 SQL 加WHERE tenant_id = ?”。列表看起来没问题,详情却按全局 ID 查询;修改接口只按 ID 更新;明细表虽然有tenant_id,却通过单独的request_id关联了另一家公司的主单。真正发生串数据的地方,往往不在最显眼的列表页面。
上一篇已经确定了可信的当前租户上下文。本文继续往下走:**拿到这个租户身份后,数据怎样写进去、查出来、关联起来和更新成功?**使用两家虚构公司作为固定案例。甲公司t_a和乙公司t_b都有编号为PR-001的申请单;全局记录 ID 分别为1001和2001。账号u_07当前选中乙公司,即使它也加入了甲公司,这次请求仍只能操作乙公司的数据。
示例环境:Java 17、Spring Boot 风格服务层、MySQL 8.x/InnoDB。下面的表和代码是可复核的教学模型,不包含实际产品的数据结构。
目录
- 一个列表正确、详情却串数据的案例
- 先确定数据归属和约束
- 创建和列表:不要让客户端决定归属
- 详情与修改:记录ID必须带上租户边界
- 关联查询:主单和明细不能跨租户拼接
- Java服务层和自动测试
- SQL验证、上线检查与边界
一、一个列表正确、详情却串数据的案例
固定数据如下:
| 租户 | 申请单 ID | 业务编号 | 状态 | 归属部门 |
|---|---|---|---|---|
甲公司t_a | 1001 | PR-001 | DRAFT | a_sales |
乙公司t_b | 2001 | PR-001 | DRAFT | b_sales |
u_07当前在乙公司。乙公司的列表只显示2001,看似隔离成功;但如果详情接口执行SELECT * FROM request WHERE id = 1001,用户只需修改 URL 就能看到甲公司的内容。若修改接口也只按id=1001更新,风险就从信息泄露变成了跨租户篡改。
本文要验证四个结果:
- 乙公司会话创建申请单时,服务端写入
tenant_id=t_b,不接受请求体中的甲公司编号。 - 乙公司列表只能在
t_b内,再按用户的数据范围缩小;甲、乙都可以合法拥有PR-001。 - 乙公司会话请求详情或修改
1001,得到统一的“资源不可用”,甲公司记录不泄露也不变化。 - 乙公司的明细只能关联乙公司的主单,不能用
request_id=1001挂到甲公司的单据上。
图1:业务编号可以在各租户内重复;全局 ID 也不能成为绕过租户过滤的捷径。
二、先确定数据归属和约束
在共享库示例中,主单和明细都显式保存tenant_id。主单有全局唯一的id,但业务编号按(tenant_id, request_no)唯一;明细通过(tenant_id, request_id)关联主单。这样的模型同时表达两件事:同一租户不能重复使用业务编号,不同租户可以使用相同编号;明细归属必须与主单一致。
CREATETABLEdemo_tenant(tenant_idVARCHAR(32)PRIMARYKEY)ENGINE=InnoDB;CREATETABLEdemo_request(idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,request_noVARCHAR(40)NOTNULL,owner_account_idVARCHAR(32)NOTNULL,dept_idVARCHAR(32)NOTNULL,statusVARCHAR(20)NOTNULL,versionBIGINTNOTNULLDEFAULT0,UNIQUEKEYuk_tenant_request_no(tenant_id,request_no),UNIQUEKEYuk_tenant_request_id(tenant_id,id),KEYidx_tenant_dept(tenant_id,dept_id,id),CONSTRAINTfk_request_tenantFOREIGNKEY(tenant_id)REFERENCESdemo_tenant(tenant_id))ENGINE=InnoDB;CREATETABLEdemo_request_item(idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,request_idBIGINTNOTNULL,item_nameVARCHAR(80)NOTNULL,amountDECIMAL(12,2)NOTNULL,KEYidx_item_request(tenant_id,request_id),CONSTRAINTfk_item_requestFOREIGNKEY(tenant_id,request_id)REFERENCESdemo_request(tenant_id,id))ENGINE=InnoDB;这里的组合唯一键(tenant_id, id)看上去与id主键重复,但它为明细的复合外键提供了明确、唯一的被引用键。MySQL 对外键和被引用键的索引有要求;不要依赖某些旧版本允许引用非唯一索引的兼容行为。若实际系统采用分库或其他存储模型,约束实现会不同,但“子资源不能挂到另一个租户的父资源”这一规则不能省。
可以插入两条同编号的主单,预期都成功:
INSERTINTOdemo_tenant(tenant_id)VALUES('t_a'),('t_b');INSERTINTOdemo_request(id,tenant_id,request_no,owner_account_id,dept_id,status)VALUES(1001,'t_a','PR-001','u_07','a_sales','DRAFT'),(2001,'t_b','PR-001','u_07','b_sales','DRAFT');下面两条语句应分别执行并分别失败:第一条触发租户内业务编号唯一键,第二条触发明细的复合外键。失败本身就是验证结果,不要为了让导入脚本“一路绿灯”而关闭外键检查。
-- 预期:uk_tenant_request_no 唯一键冲突。INSERTINTOdemo_request(id,tenant_id,request_no,owner_account_id,dept_id,status)VALUES(2002,'t_b','PR-001','u_07','b_sales','DRAFT');-- 预期:fk_item_request 外键冲突;1001 是甲公司的主单。INSERTINTOdemo_request_item(id,tenant_id,request_id,item_name,amount)VALUES(3001,'t_b',1001,'设备',900.00);数据库约束在这里负责挡住错误关联;它不能代替接口的账号授权和数据范围校验。
图2:主单和明细同时携带租户归属,让错误关联在写入时就暴露。
三、创建和列表:不要让客户端决定归属
创建接口的 DTO 不需要tenantId字段。服务端从上一篇建立的可信TenantContext中取得当前租户,并检查申请部门确实属于该租户、当前成员有创建权限。无论请求体是否被人私自加上tenant_id=t_a,都不能改变t_b会话的写入归属。
创建主单与明细应在同一个本地事务里提交。若主单已插入、明细校验失败,事务回滚;不能留下只写了一半的申请。外部附件上传不属于这笔数据库事务,其归属与交付校验留到下一篇讨论。
列表查询要把租户边界放在所有业务数据范围之前。例如普通成员只能看自己提交的单据:
SELECTid,request_no,status,dept_idFROMdemo_requestWHEREtenant_id=:trustedTenantIdANDowner_account_id=:authenticatedAccountIdORDERBYidDESC;部门主管可以额外查看授权部门,但不能把“本部门”写成一个来自前端的自由参数。部门范围应由成员身份解析出来,并与tenant_id同时约束。若可访问部门集合为空,应返回空结果,不应因为构造 SQL 时省掉IN (...)条件而变成全租户查询。租户管理员有更宽的数据范围,也仍被当前tenant_id限定。
列表中的过滤、排序和分页同样不能改变边界。建议为高频查询配置以tenant_id开头的复合索引,再用真实查询计划确认效果;不要仅为“看起来有索引”而给每个字段都建索引。
四、详情与修改:记录ID必须带上租户边界
详情接口不能先按 ID 取整条记录,再检查归属并决定是否返回。那样虽然最终可能拒绝,但中途日志、缓存、事件或对象映射已经接触到另一租户的数据。更稳的是数据访问层直接把两者作为定位条件:
SELECTid,tenant_id,request_no,owner_account_id,dept_id,status,versionFROMdemo_requestWHEREtenant_id=:trustedTenantIdANDid=:requestId;查到记录后,还要检查用户在本租户内的数据范围。查询不到时,对外统一返回“资源不可用”或 404,不区分“这个 ID 不存在”和“属于另一家公司”,避免泄露存在性。对内审计可以保留脱敏的拒绝原因,但不应向普通调用方暴露另一租户的编号和状态。
修改接口除了租户和 ID,还要有业务状态与并发版本条件:
UPDATEdemo_requestSETstatus='SUBMITTED',version=version+1WHEREtenant_id=:trustedTenantIdANDid=:requestIdANDstatus='DRAFT'ANDversion=:expectedVersion;影响 1 行表示该记录在本租户且仍为预期状态,修改成功。影响 0 行时,不能直接说“另一个租户的单据不存在”或“状态冲突”;可在本租户范围内重新查询:查不到就按资源不可用处理,查得到再判断版本或状态变化。任何补查都不应去掉tenant_id。批量更新、删除与状态推进也遵循相同原则,不能只有单条接口安全。
图3:列表隔离只覆盖一个入口;详情和修改仍需独立守住相同边界。
五、关联查询:主单和明细不能跨租户拼接
只给明细表加tenant_id还不够。如果关联条件写成item.request_id = request.id,某些脏数据、迁移数据或外键未启用的环境仍可能拼出跨租户结果。关联查询应同时比较双方的租户归属,并从主单入口再次限定当前租户:
SELECTr.id,r.request_no,i.idASitem_id,i.item_name,i.amountFROMdemo_request rJOINdemo_request_item iONi.request_id=r.idANDi.tenant_id=r.tenant_idWHEREr.tenant_id=:trustedTenantIdANDr.id=:requestId;复合外键保护未来写入;联表条件保护读取语义。两者叠加,尤其适合已有历史数据、人工导入或迁移过程复杂的系统。上线前也要核对原有明细归属,不能在脏数据上直接加外键后假定一切正常。若明细属于独立生命周期对象,还应限制它自己的创建、修改和删除接口,而不是只在主单查询时处理。
同样的思路适用于审批记录、评论、标签和文件元数据:只要是租户内的子资源,就需要说明它归属哪个父资源、能否被跨租户 ID 引用。不过文件内容与下载链接还有额外风险,下一篇单独讲,不在本文硬塞。
六、Java服务层和自动测试
下面的服务层示例只保留核心路径。TenantContext由已认证请求解析得到;ScopePolicy负责本租户内的部门与动作权限。代码没有让创建命令携带tenantId,也没有让详情按全局 ID 直接读取。
recordTenantContext(StringtenantId,StringaccountId){}recordCreateCommand(StringrequestNo,StringdeptId,List<ItemCommand>items){}recordItemCommand(Stringname,BigDecimalamount){}recordRequestRow(longid,StringtenantId,StringownerId,StringdeptId,Stringstatus,longversion){}interfaceRequestRepository{longinsert(StringtenantId,StringrequestNo,StringownerId,StringdeptId);voidinsertItems(StringtenantId,longrequestId,List<ItemCommand>items);Optional<RequestRow>findByTenantAndId(StringtenantId,longid);intsubmitIfDraft(StringtenantId,longid,longexpectedVersion);}interfaceScopePolicy{booleancanCreateIn(StringtenantId,StringaccountId,StringdeptId);booleancanRead(TenantContextcontext,RequestRowrow);booleancanSubmit(TenantContextcontext,RequestRowrow);}finalclassRequestService{privatefinalRequestRepositoryrequests;privatefinalScopePolicyscope;RequestService(RequestRepositoryrequests,ScopePolicyscope){this.requests=requests;this.scope=scope;}@Transactionalpubliclongcreate(TenantContextcontext,CreateCommandcommand){if(!scope.canCreateIn(context.tenantId(),context.accountId(),command.deptId()))thrownewAccessDeniedException("DEPARTMENT_UNAVAILABLE");longid=requests.insert(context.tenantId(),command.requestNo(),context.accountId(),command.deptId());requests.insertItems(context.tenantId(),id,command.items());returnid;}publicRequestRowdetail(TenantContextcontext,longid){RequestRowrow=requests.findByTenantAndId(context.tenantId(),id).orElseThrow(()->newResourceUnavailableException());if(!scope.canRead(context,row))thrownewResourceUnavailableException();returnrow;}@Transactionalpublicvoidsubmit(TenantContextcontext,longid,longexpectedVersion){RequestRowrow=detail(context,id);if(!scope.canSubmit(context,row))thrownewAccessDeniedException("ACTION_FORBIDDEN");if(requests.submitIfDraft(context.tenantId(),id,expectedVersion)!=1)thrownewIllegalStateException("STATE_OR_VERSION_CHANGED");}}ResourceUnavailableException在示例中代表项目自己的统一不可见异常,需要在接口层映射为不暴露资源存在性的响应。@Transactional用 Spring 声明式事务;真实项目还要补请求参数校验、重复提交控制,以及ScopePolicy所用的成员和部门数据的一致性策略。若创建命令里出现客户端自行添加的tenantId字段,反序列化层也应拒绝或忽略,不能覆盖context.tenantId()。
最少验证三种路径。示例测试使用 JUnit 5、AssertJ 与 Mockito 风格,实际项目应再用 MySQL 集成测试确认唯一键、复合外键和 SQL 查询行为。
@TestvoidbSessionCannotReadARequestByGuessingItsGlobalId(){varcontext=newTenantContext("t_b","u_07");when(requests.findByTenantAndId("t_b",1001L)).thenReturn(Optional.empty());assertThatThrownBy(()->service.detail(context,1001L)).isInstanceOf(ResourceUnavailableException.class);verify(requests).findByTenantAndId("t_b",1001L);verify(requests,never()).findByTenantAndId("t_a",1001L);}@TestvoidcreateUsesTrustedTenantForRequestAndItems(){varcontext=newTenantContext("t_b","u_07");varcommand=newCreateCommand("PR-002","b_sales",List.of(newItemCommand("设备",newBigDecimal("900.00"))));when(scope.canCreateIn("t_b","u_07","b_sales")).thenReturn(true);when(requests.insert("t_b","PR-002","u_07","b_sales")).thenReturn(2002L);assertThat(service.create(context,command)).isEqualTo(2002L);verify(requests).insertItems(eq("t_b"),eq(2002L),anyList());}@TestvoidstaleVersionCannotSubmitTwice(){varcontext=newTenantContext("t_b","u_07");varrow=newRequestRow(2001L,"t_b","u_07","b_sales","DRAFT",3);when(requests.findByTenantAndId("t_b",2001L)).thenReturn(Optional.of(row));when(scope.canRead(context,row)).thenReturn(true);when(scope.canSubmit(context,row)).thenReturn(true);when(requests.submitIfDraft("t_b",2001L,2L)).thenReturn(0);assertThatThrownBy(()->service.submit(context,2001L,2L)).hasMessage("STATE_OR_VERSION_CHANGED");}第二个测试证明由服务端上下文控制主单和明细写入归属;第三个测试证明旧版本不能重复推进。它们不能证明 SQL 一定写对,因此还需要真实数据库中的外键失败用例、跨租户详情用例和并发提交用例。尤其要防止在一个 Mock 测试通过后,把findByTenantAndId的 SQL 实现写成按 ID 查询。
图4:服务层明确传递租户边界,数据库约束和实际 SQL 仍需独立测试。
七、SQL验证、上线检查与边界
使用前面的两条固定记录,在乙公司上下文运行下面的查询。预期只看到2001;详情查询甲公司的1001为 0 行,跨租户修改影响 0 行,甲公司的记录仍是DRAFT。
-- 乙公司列表:预期只有 2001。SELECTid,request_noFROMdemo_requestWHEREtenant_id='t_b'ORDERBYid;-- 乙公司尝试读取甲公司 ID:预期 0 行。SELECTidFROMdemo_requestWHEREtenant_id='t_b'ANDid=1001;-- 乙公司尝试修改甲公司 ID:预期影响 0 行。UPDATEdemo_requestSETstatus='SUBMITTED',version=version+1WHEREtenant_id='t_b'ANDid=1001ANDstatus='DRAFT'ANDversion=0;-- 脏数据巡检:预期 0 行。上线前应核对历史导入记录。SELECTi.id,i.tenant_idASitem_tenant,r.tenant_idASrequest_tenantFROMdemo_request_item iJOINdemo_request rONr.id=i.request_idWHEREi.tenant_id<>r.tenant_id;由于复合外键,最后一个查询在新库中应始终为 0 行;它主要用于迁移前、外键未启用或旧数据来源复杂的环境。还要检查 ORM 原生 SQL、报表 SQL、批量更新、管理脚本是否绕过统一数据访问层。只有“正常列表没串”远远不够。
上线验收建议至少覆盖以下矩阵:
| 操作 | 正向结果 | 必测的拒绝结果 |
|---|---|---|
| 创建主单和明细 | 当前租户写入成功,同租户关联 | 请求体伪造归属、部门不属于当前租户 |
| 列表及分页 | 只返回当前租户且符合数据范围的记录 | 空部门范围不能退化成全租户列表 |
| 详情 | 当前租户授权记录可读 | 猜另一租户的全局 ID 不泄露 |
| 修改和状态推进 | 租户、状态、版本均符合时影响 1 行 | 跨租户 ID 或旧版本影响 0 行 |
| 关联查询 | 明细只随本租户主单返回 | 不能把乙公司明细挂到甲公司主单 |
独立数据库或独立 schema 的产品不一定需要在每张表里重复tenant_id,但也必须在进入数据库前确认路由租户,不能让客户端自由决定数据源。平台运维若确需跨租户查询,应使用另行授权、范围受限且可审计的流程,不应在普通业务 SQL 里添加“超级管理员就去掉租户条件”的捷径。
数据隔离是一组贯穿写入、读取、关联和修改的约束,不是一条列表过滤条件。本文把数据库记录守住了,下一篇继续处理文件与导出:那里的内容可能已经离开数据库,归属和下载授权更容易被忽略。
图5:每条路径都应同时验证允许访问与拒绝越界,不能用列表测试代表整个系统。
参考资料:
- OWASP:多租户安全检查清单
- MySQL 8.4 官方手册:外键约束与索引要求
- MySQL 8.4 官方手册:唯一约束