Appearance
元数据过滤与多租户隔离:检索层的硬边界
1. 本节产出
一套多租户隔离实现:租户 ID 从请求到检索全程透传、过滤在检索层强制执行、越权访问有断言测试守护。并且能用自动化测试证明 A 租户看不到 B 租户的任何内容。
2. 前置依赖
- 02-07 Document 元数据设计:片段已有
tenantId/acl - 02-11 引用溯源与拒答
3. 为什么隔离必须在检索层做
三种做法的对比:
| 做法 | 机制 | 问题 |
|---|---|---|
| ❌ Prompt 约束 | 「只回答 tenantA 的内容」 | 上下文里已经混入了 B 的数据,模型可能仍引用。不可靠 |
| ⚠️ 结果后过滤 | 检索后按 tenantId 筛掉 | topK 被无关数据占满,召回率下降;且已花钱 |
| ✅ 检索层过滤 | 把 tenantId 作为过滤条件下推到向量库 | 根本不检索,省算力且确定安全 |
判断标准:隔离必须是「检索不到」,不能是「检索到了但不给你看」。后者在实现上总有漏洞(比如某条链路忘了过滤),前者是确定性的。
一个真实事故:某 SaaS 产品用「结果后过滤」,后来新增了一个导出接口,开发忘了加过滤,直接把全租户数据导出了。如果是检索层过滤,这个接口根本拿不到别人的数据。
4. 核心原理
4.1 tenantId 的全链路透传
HTTP 请求(Header: X-Tenant-Id 或 JWT claim)
│
▼ TenantContextFilter(线程上下文 / Reactor Context)
ThreadLocal / Reactor Context
│
▼ Service 层:从上下文取,不接受参数传入
FilterBuilder.build(tenantId, acl, now)
│
▼ 下推到向量库与 ES
WHERE metadata->>'tenantId' = 'acme'关键设计:Service 层不接受 tenantId 作为参数,只从上下文取。
java
// 错误:tenantId 作为参数传来传去,任何一处漏传就是越权
public Answer ask(String q, String tenantId) { ... }
// 正确:从上下文取,无法漏传
public Answer ask(String q) {
String tenantId = TenantContext.require(); // 缺失就抛异常
...
}4.2 三层隔离模型
| 层 | 隔离什么 | 实现 |
|---|---|---|
| 租户(硬) | 不同企业/团队的数据 | tenantId == 'xxx' |
| 权限(软) | 同租户内不同角色 | acl contains 'dept:finance' |
| 个人(可选) | 个人上传的私有文档 | acl contains 'user:1001' |
租户层是硬隔离,必须无条件生效;权限层按用户角色动态构造。
4.3 过滤表达式注入的风险
java
// 危险:直接拼接用户输入
String filter = "tenantId == '" + userInput + "'";
// 用户输入: acme' OR '1'=='1
// 结果: tenantId == 'acme' OR '1'=='1' → 全库可见这就是过滤表达式注入。防御:
- tenantId 不允许来自用户输入,只从认证信息(JWT claim)取;
- 值做白名单校验(只允许字母数字和连字符);
- 优先用参数化过滤(如果向量库支持)。
4.4 缓存与隔离的冲突
缓存 key = hash(question) → 危险!不同租户会命中同一个缓存
缓存 key = hash(tenantId + question) → 正确这是引入缓存后最常见的越权漏洞:缓存 key 里漏了 tenantId,A 租户的查询结果被 B 租户命中。见 02-15 缓存章节会再次强调。
5. 代码走查
5.1 租户上下文
java
// ch02-rag/src/main/java/com/aitech/rag/tenant/TenantContext.java
public final class TenantContext {
private static final ThreadLocal<String> TL = new ThreadLocal<>();
public static void set(String tenantId) { TL.set(tenantId); }
/** 缺失即抛异常——宁可失败也不要「无租户」检索 */
public static String require() {
String t = TL.get();
if (t == null || t.isBlank()) {
throw new IllegalStateException("租户上下文缺失");
}
return t;
}
public static void clear() { TL.remove(); }
}5.2 过滤器
java
// ch02-rag/src/main/java/com/aitech/rag/tenant/TenantContextFilter.java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantContextFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest http = (HttpServletRequest) req;
// 从 JWT claim 取,不信任 Header(Header 可被伪造)
String tenantId = extractFromJwt(http);
try {
TenantContext.set(tenantId);
chain.doFilter(req, res);
} finally {
TenantContext.clear(); // 必须清理,否则线程池复用会串租户
}
}
}finally 里的 clear() 是必须的。Tomcat 线程池复用线程,不清理会导致下一个请求拿到上一个请求的租户——这是非常隐蔽的串数据事故。
5.3 强制注入过滤条件
java
// ch02-rag/src/main/java/com/aitech/rag/retrieval/SecureRetriever.java
@Service
public class SecureRetriever {
private final HybridRetriever delegate;
/** 唯一的检索入口:强制带上租户与权限过滤 */
public List<RetrievalResult> retrieve(String query, int topK) {
String tenantId = TenantContext.require();
List<String> acl = SecurityContext.acl();
String filter = FilterBuilder.build(tenantId, acl, Instant.now());
log.debug("filter={}", filter);
return delegate.retrieveWithFilter(query, filter, topK);
}
}把 SecureRetriever 设为唯一入口,其他检索类不对外暴露。这样「忘记加过滤」在架构上就不可能发生。
5.4 越权断言测试
java
// ch02-rag/src/test/java/com/aitech/rag/tenant/TenantIsolationIT.java
@Testcontainers
@SpringBootTest
class TenantIsolationIT {
@Test
void A租户不应看到B租户的任何内容() {
// 灌入两个租户的文档
ingest("acme", "ACME 的年假政策是 15 天");
ingest("beta", "BETA 的年假政策是 10 天");
TenantContext.set("acme");
var hits = secureRetriever.retrieve("年假政策", 10);
assertThat(hits).isNotEmpty();
assertThat(hits).allSatisfy(h ->
assertThat(h.meta()).containsEntry("tenantId", "acme"));
String all = hits.stream().map(RetrievalResult::text)
.collect(Collectors.joining());
assertThat(all).doesNotContain("BETA"); // 关键断言
}
@Test
void 无租户上下文时应抛异常() {
TenantContext.clear();
assertThatThrownBy(() -> secureRetriever.retrieve("年假", 5))
.isInstanceOf(IllegalStateException.class);
}
@Test
void 过滤表达式注入应被拦截() {
assertThatThrownBy(() -> FilterBuilder.build("acme' OR '1'=='1", List.of(), now))
.isInstanceOf(IllegalArgumentException.class);
}
}这三个测试是本节的核心交付。特别是第一个——它用断言证明隔离生效,比任何人工检查都可靠。
6. 跑起来
bash
git checkout ch02-12-metadata-filter
mvn -q test -Dtest=TenantIsolationIT| 检查项 | 通过标准 |
|---|---|
| 租户隔离 | A 检索结果中不含 B 的任何内容 |
| 缺失租户 | 抛异常,不是「返回全部」 |
| 注入防护 | 非法 tenantId 被拒绝 |
| 线程清理 | 并发 100 次请求后无租户串号 |
| 权限过滤 | 无对应 acl 时查不到受限文档 |
并发测试要真的跑:用 JMH 或简单并发循环发 100 次不同租户的请求,验证没有串号。这是 TenantContext.clear() 是否生效的唯一可靠验证。
7. 生产避坑
TenantContext.clear()必须写在 finally 里。线程池复用导致的租户串数据是极其隐蔽的事故——表现为「偶尔看到别的公司数据」,无法稳定复现,排查成本极高。- 不要把 tenantId 从请求参数或 Header 直接取。Header 可伪造,正确来源是 JWT claim 或服务端会话。安全边界要在认证层,不要在传输层。
- 引入缓存时必须把 tenantId 放进缓存 key。这是二次引入的越权漏洞,且往往在功能测试时测不出来(因为测试时通常只用单租户)。做法:缓存 key 的构造函数强制要求租户参数。
8. 延伸与锚点
- 思考题:用户问「我们公司的年假是多少天」,「我们公司」这个指代模型能理解吗?需要改写成什么?(答案在下一课时:查询改写)
- 代码锚点:
git checkout ch02-12-metadata-filter - 下一课时:02-13 查询改写与前处理
- 对应课件:L02-12 多租户隔离