SuperAPI AI Directory

cc-multi-tenant-safety

Data & analytics

View repository

Updated 10-15 21:11

About this skill

Multi-Tenant Isolation Security Specification

When a system involves a multi-tenant architecture, prevent cross-tenant unauthorized data access.


Pitfall #1: Incorrect trust in the source of tenant context

Scenario: An interceptor/filter sets the tenant context from a request header (such as X-Tenant-Code), but does not validate its consistency with the tenantId in the authentication token

Root cause

Request headers can be arbitrarily forged by the client. If the backend only trusts the tenant identifier in the request header, an attacker only needs to modify the header to access another tenant's data.

Incorrect example

// ❌ 错误: 只信任请求头,未校验 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
// 就能读到其他租户的数据

Correct approach

// ✅ 正确: 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;
}

Checklist

  • Is the ultimate source of the tenant context based on the authentication token
  • Is the tenant identifier in the request header used only for routing/lookup, rather than directly trusted
  • Is a consistency check performed between the tenantId in the token and the tenant in the request header
  • When validation fails, is a 403 returned instead of silently allowing the request

Pitfall #1.5: Service layer calls Repository.findById(id) missing tenant filtering

Parallel to Pitfall #2: Pitfall #2 is "global filtering as a fallback", while this pitfall is "explicit defense in depth". Even if the project already uses Hibernate @Filter / MyBatis interceptors, it is still recommended that the Service layer explicitly call findByTenantIdAndId—direct primary key lookups often bypass global filtering on paths such as second-level cache hits andgetReferenceById . Using both layers together also improves readability (looking at the Service code, you can tell that tenantId is isolated).

Scenario: Even with a global filtering mechanism, developers directly repo.findById(id) in the Service can still bypass filtering—primary key queries are often treated by JPA/Hibernate as "direct lookup by ID", skipping the entity filter

Root cause

  • JPA second-level cache / findById going through the persistence context will skip @Filter
  • An attacker who obtains any ID (order number, user ID may be enumerated from other channels) can read across tenants
  • A single leak point is enough to cause disclosure: during N+1 optimization, for (X x : list) { ... repo.findById(x.foreignKey) ... }often appears, each time missing tenantId

Incorrect example

// ❌ 错误:直接按主键查
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);

Correct approach

The Repository must provide a "primary key query with tenantId" method, and the Service must always call it:

// ✅ 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));

Sniffing signals (grep for these during review/audit)

# 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

Checklist

  • The Service layer must prohibit bare findById / getOne / getReferenceById / findAllById
  • Each Repository must provide at least findByTenantIdAndId and findByTenantIdAndIdIn
  • When introducing a new Repository, primary key methods and IN methodsmustbe tenant-aware
  • CI check (optional): scan *Service.java in findById( calls and fail the build
  • The "super admin" interface of the cross-tenant management backend requires an independent endpoint + explicit comments (it must not reuse the business findById)

Pitfall #2: Lack of global tenant filtering in the data query layer

Scenario: Some queries bypass tenant filtering, resulting in cross-tenant data leakage

Root cause

It relies on developers to manually add WHERE tenant_id = ?in every query, which is easy to miss.

Incorrect example

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

Correct approach

// ✅ 方案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 = ?
}

Checklist

  • Is there a global tenant filtering mechanism (Hibernate Filter / MyBatis interceptor / base class)
  • Are new query methods automatically protected by tenant filtering
  • Has a tenant_id condition been manually added to native SQL / @Query
  • Do cross-tenant management APIs (super admin) have an independent bypass mechanism

Pitfall #3: Frontend does not handle tenant-mismatch 403

Scenario: The backend returns 403 (tenant mismatch), but the frontend does not handle it correctly, so the user sees a blank page or no prompt

Incorrect example

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

Correct approach

// ✅ 正确: 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);
  }
);

Checklist

  • Does the frontend uniformly handle the 403 status code
  • Does a tenant-mismatch 403 clear the login state and redirect to the login page
  • Is the user given a clear error message (rather than a blank page)

Pitfall #4: The tenant ID input box allows manual entry

Scenario: In the admin backend's configuration form, the tenant ID uses a manual input box and is easy to enter incorrectly

Incorrect example

// ❌ 错误: 手动输入租户 ID,容易输错
<InputNumber placeholder="请输入租户ID" />

Correct approach

// ✅ 正确: 下拉选择租户名称,提交时自动转为 tenantId
<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>

Checklist

  • Do tenant-related fields in the admin backend use dropdown selection instead of manual entry
  • Do the dropdown options display the tenant name (rather than only the ID)
  • After selecting a tenant, are related fields (such as tenantCode) automatically populated

Pitfall #5: Feature-level authorization != data-level isolation

Scenario: For features opened only to specific tenants/roles/subscription tiers, only the frontend uses isYmhwTenant / hasPermission / isPaid to hide the entry point, and the backend endpoint has no independent feature authorization check

Root cause of the problem

Frontend UI control (button hiding, menu filtering) is onlyuser experience optimization, not a security boundary. As long as any logged-in user knows the endpoint path, they can bypass the UI and call the API directly to use capabilities they should not have. This is a different dimension from "data-layer tenant isolation":

  • Data layer (Pitfalls #1/#2): The scope of data accessed (your data vs. others' data)
  • Business layer (this pitfall): The features that can be used (what features you can use vs. what others can use)

Typical scenarios:

  • Tenant-exclusive features (such as the YMHW mini-program shipping template export, available only to the Yumi Haowu tenant)
  • Role-exclusive features (only administrators can batch delete/batch export)
  • Subscription/version-exclusive features (paid-version AI analysis, enterprise-version advanced reports)
  • Feature flags (feature flag canary release)

Incorrect example

// ❌ 错误: 后端只过滤当前租户数据,没校验"该租户是否启用该功能"
@PostMapping("/orders/miniapp-shipping-template/export")
public ResponseEntity<byte[]> export(@RequestBody ExportRequest req) {
    Long tenantId = TenantContext.getTenantId();
    // 只查当前租户的订单(数据隔离 OK),但任何已登录租户都能调用这个接口
    return service.export(tenantId, req.getOrderIds());
}
// 前端通过 isYmhwTenant 隐藏入口(只是体验优化,不是安全边界)
{isYmhwTenant && <Button onClick={handleExport}>导出小程序发货模板</Button>}

Correct approach

// ✅ 正确: endpoint 入口处独立校验"该租户是否启用该功能"
@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());
}

Implementation strategy

Authorization basis Implementation method Applicable scenarios
Tenant code whitelist endpoint entry if check / @RequireTenantCode annotation + AOP Single/few tenant-exclusive
Role permissions @PreAuthorize("hasRole('ADMIN')") / Spring Security / Casbin Within the RBAC system
Subscription status endpoint entry checks whether the current subscription covers the feature SaaS by version

Priority: For 1-2 endpoints, use a direct if check (simplest, highly readable); only consider AOP/middleware abstraction when there are 3 or more restrictions of the same kind, to avoid over-engineering.

Checklist

  • Does the endpoint entry point have an independent check for "whether this feature is enabled for this user/tenant" (not relying on frontend hiding)
  • Is the authorization basis clear (tenant code / role / feature flag / subscription status)
  • On check failure, does it return 403 + a business-readable reason (not a 500 or empty response)
  • Has a penetration test of "bypassing the UI and calling the API directly" been performed (using curl/Postman to simulate an unauthorized tenant)

Multi-language examples

For complete implementation examples in Java (Spring Boot annotation AOP) / Go (Gin middleware) / TypeScript (Express middleware + NestJS Guard), see references/multi-lang-examples.md。


Checklist (multi-tenant isolation)

Authentication and authorization:

  • Is the ultimate source of the tenant context based on the authentication token
  • Is the tenant identifier in headers/parameters used only for routing and not trusted
  • Is a consistency check performed between the token tenantId and the routed tenant
  • Does a check failure return 403

Feature authorization(business layer, see trap #5):

  • For features opened only to specific tenants/roles/subscription tiers, does the endpoint entry point have an independent check
  • Is the anti-pattern of "only hiding the button on the frontend with no backend check" avoided
  • Authorization failure returns 403 + a business-readable reason
  • Has a penetration test of "bypassing the UI and calling the API directly" been performed

Data isolation:

  • Is there a global tenant filtering mechanism
  • Are newly added queries automatically protected by tenant filtering
  • For native SQL, is the tenant_id condition added manually
  • Are there test cases for cross-tenant data leakage

Frontend handling:

  • Is a 403 tenant mismatch handled correctly
  • Do tenant-related configurations use a dropdown selection
  • Does the list page display the tenant name instead of the ID

Applicable scope

  • Java: Spring Boot + JPA/Hibernate / MyBatis
  • Go: Gin + GORM / sqlx
  • Node.js: Express + Prisma / TypeORM
  • Python: FastAPI + SQLAlchemy

Rule traceability

> 📋 本回复遵循:`cc-multi-tenant-safety` - [章节名]