Skip to content
Development
Skill

/multi-tenant-safety

当代码涉及多租户隔离(TenantContext、tenantId、租户拦截器/过滤器、X-Tenant-Code)时触发。防止租户越权访问、数据串租户等安全问题。

From plugin
cc-use-exp
1k38 skills18 commands
Install
$ npx -y skills add doccker/cc-use-exp --skill multi-tenant-safety --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/multi-tenant-safety

Context preview

The summary Claude sees to decide when to auto-load this skill.

当代码涉及多租户隔离(TenantContext、tenantId、租户拦截器/过滤器、X-Tenant-Code)时触发。防止租户越权访问、数据串租户等安全问题。

SKILL.md

multi-tenant-safety.SKILL.md
name: multi-tenant-safety
description: 当代码涉及多租户隔离(TenantContext、tenantId、租户拦截器/过滤器、X-Tenant-Code)时触发。防止租户越权访问、数据串租户等安全问题。

多租户隔离安全规范

当系统涉及多租户架构时,防止租户间数据越权访问。

---

陷阱 #1: 租户上下文来源信任错误

**场景**: 拦截器/过滤器从请求头(如 `X-Tenant-Code`)设置租户上下文,但未与认证 token 中的 tenantId 做一致性校验

问题根因

请求头可以被客户端任意伪造。如果后端只信任请求头中的租户标识,攻击者只需修改 header 就能访问其他租户的数据。

错误示例

// ❌ 错误: 只信任请求头,未校验 token
@Override
public boolean preHandle(HttpServletRequest request, ...) {
    String tenantCode = request.getHeader("X-Tenant-Code");
    TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode);
    TenantContext.setTenantId(config.getTenantId());  // 直接信任 header
    return true;
}
// 攻击者拿着 tenantId=1 的 token,配上 X-Tenant-Code: OTHER_TENANT
// 就能读到其他租户的数据

正确做法

// ✅ 正确: header 只做路由定位,必须与 token tenantId 校验一致
@Override
public boolean preHandle(HttpServletRequest request, ...) {
    String tenantCode = request.getHeader("X-Tenant-Code");
    TenantMiniAppConfig config = configRepository.findByTenantCode(tenantCode);

    // 从认证 token 中取出 tenantId(真相源)
    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;
}

检查清单

  • [ ] 租户上下文的最终来源是否以认证 token 为准
  • [ ] 请求头中的租户标识是否只用于路由定位,而非直接信任
  • [ ] token 中的 tenantId 与请求头租户是否做了一致性校验
  • [ ] 校验不通过时是否返回 403 而非静默放行

---

陷阱 #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);

// ❌ 错误:N+1 修复时也漏掉
List<User> users = userIds.stream()
    .map(id -> userRepository.findById(id).orElse(null))  // ← 无租户过滤
    .toList();

// ❌ 错误:批量 IN 也忘记带 tenantId
List<User> users = userRepository.findAllById(userIds);

正确做法

Repository 必须提供「带 tenantId 的主键查询」方法,Service 一律调用它:

// ✅ Repository 强制提供租户感知方法
public interface WxUserRepository extends JpaRepository<WxUser, Long> {
    Optional<WxUser> findByTenantIdAndId(Long tenantId, Long id);
    List<WxUser> findByTenantIdAndIdIn(Long tenantId, Collection<Long> ids);
}

// ✅ Service 一律带 tenantId
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)

# 1. Service 层任何裸 findById(绝大多数应迁移)
grep -rn "Repository.findById(" src/main/java/**/service/

# 2. JpaRepository 默认方法(这些都"按 ID 直查",绕开 entity filter)
grep -rnE "(findById|getOne|getById|getReferenceById|findAllById)\(" src/main/java/

# 3. 检查是否所有 Repository 都有租户感知主键方法
grep -L "findByTenantIdAndId" src/main/java/**/repository/*Repository.java

检查清单

  • [ ] Service 层禁止裸 `findById` / `getOne` / `getReferenceById` / `findAllById`
  • [ ] 每个 Repository 至少提供 `findByTenantIdAndId` 和 `findByTenantIdAndIdIn`
  • [ ] 引入新 Repository 时,主键方法和 IN 方法**必须**租户感知
  • [ ] CI 检查(可选):扫描 `*Service.java` 里的 `findById(` 调用并失败构建
  • [ ] 跨租户管理后台的"超管"接口需要独立 endpoint + 显式注释(不能复用业务 findById)

---

陷阱 #2: 数据查询层缺少全局租户过滤

**场景**: 部分查询绕过了租户过滤,导致跨租户数据泄露

问题根因

依赖开发者在每个查询中手动加 `WHERE tenant_id = ?`,容易遗漏。

错误示例

// ❌ 错误: 忘记加租户过滤
@Query("SELECT p FROM Product p WHERE p.categoryId = :categoryId")
List<Product> findByCategoryId(@Param("categoryId") Long categoryId);
// 返回所有租户的商品

正确做法

// ✅ 方案1: JPA/Hibernate 全局过滤器(推荐)
@Entity
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = Long.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Product {
    private Long tenantId;
}

// ✅ 方案2: 基类强制携带 tenantId
public abstract class TenantAwareEntity {
    @Column(name = "tenant_id", nullable = false)
    private Long tenantId;
}

// ✅ 方案3: MyBatis 拦截器自动追加 tenant_id 条件
@Intercepts(@Signature(type = Executor.class, method = "query", ...))
public class TenantInterceptor implements Interceptor {
    // 自动在 SQL 中追加 AND tenant_id = ?
}

检查清单

  • [ ] 是否有全局租户过滤机制(Hibernate Filter / MyBatis 拦截器 / 基类)
  • [ ] 新增查询方法时是否自动受租户过滤保护
  • [ ] 原生 SQL / @Query 是否手动加了 tenant_id 条件
  • [ ] 跨租户管理接口(超级管理员)是否有独立的绕过机制

---

陷阱 #3: 前端未处理租户不匹配的 403

**场景**: 后端返回 403(租户不匹配),但前端没有正确处理,用户看到空白页或无提示

错误示例

// ❌ 错误: 只处理 401,忽略 403
request.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      clearAuth();
      redirectToLogin();
    }
    return Promise.reject(error);  // 403 被静默吞掉
  }
);

正确做法

// ✅ 正确: 403 租户不匹配时清理登录态并跳转
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);
  }
);

检查清单

  • [ ] 前端是否统一处理了 403 状态码
  • [ ] 租户不匹配的 403 是否清理登录态并跳转登录页
  • [ ] 是否给用户明确的错误提示(而非空白页)

---

陷阱 #4: 租户 ID 输入框允许手动输入

**场景**: 管理后台的配置表单中,租户 ID 使用手动输入框,容易输错

错误示例

// ❌ 错误: 手动输入租户 ID,容易输错
<InputNumber placeholder="请输
Read more
Ships withcc-use-exp

保留你熟悉的 CLI/IDE,让 Claude Code、Gemini CLI、Codex、Cursor、GitHub Copilot 开箱即用 按费力度从低到高,用最少操作获得最大帮助 不是提示词集合,而是一套可维护的 AI 协作配置系统。

Get the whole plugin

Other skills on cc-use-exp.