| name | multi-tenant-safety |
| description | 当代码涉及多租户隔离(TenantContext、tenantId、租户拦截器/过滤器、X-Tenant-Code)时触发。防止租户越权访问、数据串租户等安全问题。 |
多租户隔离安全规范
当系统涉及多租户架构时,防止租户间数据越权访问。
陷阱 #1: 租户上下文来源信任错误
场景: 拦截器/过滤器从请求头(如 X-Tenant-Code)设置租户上下文,但未与认证 token 中的 tenantId 做一致性校验
问题根因
请求头可以被客户端任意伪造。如果后端只信任请求头中的租户标识,攻击者只需修改 header 就能访问其他租户的数据。
错误示例
@Override
public boolean preHandle(HttpServletRequest request, ...) {
String tenantCode = request.getHeader("X-Tenant-Code");
TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode);
TenantContext.setTenantId(config.getTenantId());
return true;
}
正确做法
@Override
public boolean preHandle(HttpServletRequest request, ...) {
String tenantCode = request.getHeader("X-Tenant-Code");
TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode);
Long tokenTenantId = (Long) request.getAttribute("tokenTenantId");
if (tokenTenantId != null && !tokenTenantId.equals(config.getTenantId())) {
response.setStatus(403);
response.getWriter().write("{\"code\":403,\"message\":\"租户信息不匹配\"}");
return false;
}
TenantContext.setTenantId(config.getTenantId());
return true;
}
检查清单
陷阱 #1.5: Service 层调用 Repository.findById(id) 缺租户过滤
与陷阱 #2 并行:陷阱 #2 是「全局过滤兜底」,本陷阱是「显式深度防御」。即使项目已用 Hibernate @Filter / MyBatis 拦截器,仍建议 Service 层显式调用 findByTenantIdAndId——主键直查在二级缓存命中、getReferenceById 等路径上常常绕过全局过滤。两层一起用,可读性也更好(看 Service 代码就知道隔离了 tenantId)。
场景: 即使有了全局过滤机制,开发者在 Service 里直接 repo.findById(id) 仍可能绕过过滤——主键查询常常被 JPA/Hibernate 当成"按 ID 直查",跳过 entity filter
问题根因
- JPA 二级缓存 /
findById 走持久化上下文,会跳过 @Filter
- 攻击者拿到任意 ID(订单号、用户 ID 可能从其他渠道枚举)就能跨租户读
- 单个漏点就足以泄露:N+1 优化时常常出现
for (X x : list) { ... repo.findById(x.foreignKey) ... },每次都漏 tenantId
错误示例
WxUser buyer = wxUserRepository.findById(buyerUserId).orElse(null);
List<User> users = userIds.stream()
.map(id -> userRepository.findById(id).orElse(null))
.toList();
List<User> users = userRepository.findAllById(userIds);
正确做法
Repository 必须提供「带 tenantId 的主键查询」方法,Service 一律调用它:
public interface WxUserRepository extends JpaRepository<WxUser, Long> {
Optional<WxUser> findByTenantIdAndId(Long tenantId, Long id);
List<WxUser> findByTenantIdAndIdIn(Long tenantId, Collection<Long> ids);
}
WxUser buyer = wxUserRepository.findByTenantIdAndId(tenantId, buyerUserId).orElse(null);
Map<Long, WxUser> userMap = wxUserRepository
.findByTenantIdAndIdIn(tenantId, userIds)
.stream()
.collect(Collectors.toMap(WxUser::getId, u -> u));
嗅探信号(review/审计时按这些 grep)
grep -rn "Repository.findById(" src/main/java/**/service/
grep -rnE "(findById|getOne|getById|getReferenceById|findAllById)\(" src/main/java/
grep -L "findByTenantIdAndId" src/main/java/**/repository/*Repository.java
检查清单
陷阱 #2: 数据查询层缺少全局租户过滤
场景: 部分查询绕过了租户过滤,导致跨租户数据泄露
问题根因
依赖开发者在每个查询中手动加 WHERE tenant_id = ?,容易遗漏。
错误示例
@Query("SELECT p FROM Product p WHERE p.categoryId = :categoryId")
List<Product> findByCategoryId(@Param("categoryId") Long categoryId);
正确做法
@Entity
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = Long.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Product {
private Long tenantId;
}
public abstract class TenantAwareEntity {
@Column(name = "tenant_id", nullable = false)
private Long tenantId;
}
@Intercepts(@Signature(type = Executor.class, method = "query", ...))
public class TenantInterceptor implements Interceptor {
}
检查清单
陷阱 #3: 前端未处理租户不匹配的 403
场景: 后端返回 403(租户不匹配),但前端没有正确处理,用户看到空白页或无提示
错误示例
request.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
clearAuth();
redirectToLogin();
}
return Promise.reject(error);
}
);
正确做法
request.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
const message = error.response?.data?.message || '';
if (status === 401) {
clearAuth();
redirectToLogin();
} else if (status === 403 && message.includes('租户')) {
clearAuth();
redirectToLogin();
showToast('登录状态异常,请重新登录');
}
return Promise.reject(error);
}
);
检查清单
陷阱 #4: 租户 ID 输入框允许手动输入
场景: 管理后台的配置表单中,租户 ID 使用手动输入框,容易输错
错误示例
<InputNumber placeholder="请输入租户ID" />
正确做法
<Select
placeholder="请选择租户"
onChange={(value) => {
form.setFieldsValue({ tenantId: value });
const tenant = tenants.find(t => t.id === value);
form.setFieldsValue({ tenantCode: tenant?.tenantCode });
}}
>
{tenants.map(t => (
<Option key={t.id} value={t.id}>
{t.tenantName} / {t.tenantCode}
</Option>
))}
</Select>
检查清单
陷阱 #5: 功能级授权 ≠ 数据级隔离
场景: 仅对特定租户/角色/订阅级开放的功能,只在前端用 isYmhwTenant / hasPermission / isPaid 隐藏入口,后端 endpoint 没有独立的功能授权校验
问题根因
前端 UI 控制(按钮隐藏、菜单过滤)只是用户体验优化,不是安全边界。任意已登录用户只要知道 endpoint 路径,绕过 UI 直接调用接口,就能使用本不该有的能力。这与"数据层租户隔离"是两个不同维度:
- 数据层(陷阱 #1/#2):访问的数据范围(你的数据 vs 他人的数据)
- 业务层(本陷阱):可以使用的功能(你能用什么功能 vs 别人能用什么功能)
典型场景:
- 租户专属功能(如 YMHW 小程序发货模板导出,仅鱼米好物租户可用)
- 角色专属功能(仅管理员可批量删除/批量导出)
- 订阅/版本专属功能(付费版 AI 分析、企业版高级报表)
- 功能开关(feature flag 灰度发布)
错误示例
@PostMapping("/orders/miniapp-shipping-template/export")
public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) {
Long tenantId = TenantContext.getTenantId();
return service.export(tenantId, req.getOrderIds());
}
{isYmhwTenant && <Button onClick={handleExport}>导出小程序发货模板</Button>}
正确做法
@PostMapping("/orders/miniapp-shipping-template/export")
public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) {
Long tenantId = TenantContext.getTenantId();
TenantMiniAppConfig config = configRepository.findByTenantId(tenantId)
.orElseThrow(() -> new BusinessException(403, "未启用小程序发货模板导出"));
if (!"YMHW".equalsIgnoreCase(config.getTenantCode())) {
throw new BusinessException(403, "仅鱼米好物租户支持导出小程序发货模板");
}
return service.export(tenantId, req.getOrderIds());
}
实现策略
| 授权依据 | 实现方式 | 适用场景 |
|---|
| 租户编码白名单 | endpoint 入口 if 校验 / @RequireTenantCode 注解 + AOP | 单个/少量租户专属 |
| 角色权限 | @PreAuthorize("hasRole('ADMIN')") / Spring Security / Casbin | RBAC 体系内 |
| 订阅状态 | endpoint 入口校验当前订阅是否覆盖该功能 | SaaS 分版本 |
优先级:1-2 个 endpoint 用直接 if 校验(最简、可读性高);3 个以上同样限制再考虑 AOP/middleware 抽象,避免过度工程化。
检查清单
多语言示例
完整的 Java(Spring Boot 注解 AOP)/ Go(Gin middleware)/ TypeScript(Express middleware + NestJS Guard)实现示例见 references/multi-lang-examples.md。
检查清单(多租户隔离)
认证与授权:
功能授权(业务层,参见陷阱 #5):
数据隔离:
前端处理:
适用范围
- Java: Spring Boot + JPA/Hibernate / MyBatis
- Go: Gin + GORM / sqlx
- Node.js: Express + Prisma / TypeORM
- Python: FastAPI + SQLAlchemy
规则溯源
> 📋 本回复遵循:`multi-tenant-safety` - [章节名]