我们的内部 Agent 能力平台同时服务两类调用方:坐在浏览器前的员工,和背后有成百上千终端客户的外部业务系统(通过 API Key 接入,让 Agent 能力嵌入它们自己的产品)。这两类调用方对"隔离"的要求完全不同:前者是典型的多租户 SaaS 问题,后者要求平台在空间内部再切一层"终端用户"维度的数据隔离——而平台本身并不认识这些终端用户。
这篇文章完整记录我们落地的三层隔离模型:
- 工作空间隔离(强制):
space_id是一切数据隔离的基本单元; - 两阶段 JWT + 双模式 API Key:交互式与自动化两种认证通道如何统一汇入同一个请求上下文;
- 终端用户透传隔离(可选):
end_user_id/end_sub_user_id两级不透明字符串,平台只透传和过滤,不解析含义。
其中第三部分——"隔离作为数据组织而非安全边界"的信任模型——是整个设计里最值得展开讨论的地方。
1. 概念模型:只有"用户"和"工作空间"
平台的身份体系里只有两个核心实体,刻意没有"公司"或"租户"层级:
| 概念 | 来源 | 说明 |
|---|---|---|
| 用户 | 企业统一身份系统 | 唯一身份源。平台不存密码、不提供注册,登录时调用身份系统的 HTTP API 验密 |
| 工作空间 | 平台自建 | 用户自由创建的数据隔离单元。空间内的 Agent、知识库、工具、画像、记忆完全隔离 |
设计理念参考 Notion:
- 任何用户都可以创建自己的空间,无需审批;
- 一个用户可以同时属于多个空间,且在不同空间里角色不同;
- 空间即隔离边界:同一空间内数据互通,跨空间完全不可见;
- 登录后选择空间进入,可随时切换。
空间内固定三个角色:Owner(全部权限,含删除空间)、Admin(管理成员与内容,不能删空间)、Member(普通使用)。角色规则刻意简单:添加成员只能设为 MEMBER 或 ADMIN,不能移除 Owner,只有 Owner 能改角色。
1.1 外部身份源:一个 Gateway,而不是一张用户表
平台没有用户表。登录时后端拿到账号密码,调用企业统一身份系统的验密接口(POST /system/auth/verify),拿到用户标识后自行决定"这个人能进哪些空间、角色是什么"。
选型要点:
- 安全边界清晰:密码验签留在身份系统,平台永不接触密码哈希;
- 松耦合:身份源的表结构、字段名、加密方式变化与平台无关;
- 防腐层内建:在 COLA 分层里,身份系统就是 domain 层的一个 Gateway SPI:
package com.example.platform.domain.auth;
public interface IdentityGateway {
/** 验证账号密码,返回用户标识。失败抛 BusinessException(UNAUTHORIZED) */
ExternalUser verifyCredentials(String username, String password);
}
// 领域值对象,不含密码
public record ExternalUser(
Long userId, String username, String nickname, Integer status, String avatar
) {}
infrastructure 层的实现是一个带超时(5s)、重试(1 次)、熔断(连续失败断路)的 HTTP 客户端。容错策略:身份系统不可达 → 登录返回 503;返回认证失败 → 透传错误码给前端。整个平台对身份系统的依赖被压缩为一个接口和一个 URL 配置。
2. 两阶段 JWT:先选人,再选空间
多空间成员资格带来一个认证上的小难题:登录成功的瞬间,系统还不知道用户要用哪个空间的身份。我们的解法是两阶段签发 JWT。
2.1 流程
浏览器 平台后端 企业统一身份系统
│ │ │
│ POST /auth/login │ │
│ {user, password} │ │
│ ─────────────────────────→ │ POST /verify │
│ │ ─────────────────────────→ │
│ │ {user_id, username, ...} │
│ │ ←───────────────────────── │
│ │ │
│ │ 查该用户可访问的空间列表 │
│ │ (首次登录自动创建私人空间)│
│ │ │
│ {user, spaces, 预选 JWT} │ │
│ ←───────────────────────── │ │
│ │
│ 前端展示空间选择界面 │
│ │
│ POST /auth/switch-space │
│ {spaceId} │
│ ─────────────────────────→ │ 校验成员资格 │
│ {选定 JWT} │ │
│ ←───────────────────────── │ │
- 预选 JWT:
selected_space = null。它只有一个合法用途——调用/auth/switch-space。网关过滤器对除 login / switch-space 之外的所有路径拒绝预选 JWT。 - 选定 JWT:带完整空间上下文。结构如下:
{
"sub": "1024",
"username": "zhangsan",
"selected_space": "ws_abc123",
"space_role": "owner",
"iat": 1718700000,
"exp": 1718786400
}
两个要点:
- JWT 是平台签发的,不是身份系统签发的。 身份系统只回答"这个人是谁","他能进哪些空间、是什么角色"是平台自己的授权决策。职责切分干净。
- 切换空间不需要重新登录。
switch-space校验目标空间的成员资格后用新的selected_space重签 JWT 即可——空间切换是平台内部操作,与身份系统无关。
2.2 网关过滤器:一切隔离的入口
所有请求(除公开路径)经过认证过滤器,职责是把认证信息翻译成统一的请求上下文:
请求到达
│
▼
1. 提取 Authorization: Bearer <JWT>
2. 验签名 + 过期检查
3. 若 selected_space 为 null → 仅放行 /auth/switch-space
4. 提取信息注入 RequestContext:
├── userId (sub)
├── spaceId (selected_space)
├── spaceRole (space_role)
├── endUserId (X-End-User-Id Header,可选)
└── endSubUserId (X-End-Sub-User-Id Header,可选)
5. 生成 trace_id
6. 放行;请求结束 doFinally → RequestContext.clear()
从这一步往下,Controller、AppService、Gateway 都只看 RequestContext,不再关心认证方式。这是后面统一 JWT 与 API Key 两种通道的关键。
3. 双模式认证:JWT 与 API Key 汇入同一上下文
交互式浏览器会话用短期 JWT,自动化调用(Agent、外部服务)用长期可吊销的 API Key。API Key 分两种:
| UserApiKey | SpaceApiKey | |
|---|---|---|
| 绑定对象 | 用户 | 空间 |
| 创建者 | 任何用户(给自己创建) | 空间 Owner / Admin |
| 代表身份 | 特定用户,等同本人操作 | 服务/系统身份,不绑定人 |
| 访问范围 | 该用户加入的所有空间 | 仅绑定的那一个空间 |
| 请求 Header | X-API-Key + X-Space-ID |
X-API-Key(Key 自带空间,无需再传) |
| 典型场景 | 个人 Agent 自动执行任务 | 外部业务系统后端集成 |
Key 的工程细节:前缀 lsk_ + 32 位随机 hex;创建时完整明文只返回一次,数据库存 SHA-256 hash(唯一索引),校验时同样 hash 后比对;可吊销(status: ACTIVE/REVOKED)。
认证过滤器的双模式分发逻辑:
请求到达
│
├─ X-API-Key 存在?
│ │ 是 → hash 查 Key 表
│ │ ├─ 命中 UserApiKey:
│ │ │ · 提取 user_id
│ │ │ · 取 X-Space-ID Header
│ │ │ · 校验该用户是否为该空间成员
│ │ └─ 命中 SpaceApiKey:
│ │ · 提取 space_id(Key 自带)
│ │ · user_id = null(服务身份)
│ │ · X-Space-ID 即使传了也忽略
│ │
│ └─ 否 → Authorization: Bearer <JWT>?走 JWT 校验
│
▼
注入 RequestContext(userId 可能为 null, spaceId, authType)
两条通道最终汇入同一个 RequestContext。下游业务代码完全不知道也不关心请求来自浏览器还是外部服务——成员资格校验、角色判断、数据过滤逻辑只写一份。这是这个设计里最省心的部分:认证是可插拔的,授权和隔离是统一的。
4. 数据层隔离:强制 space_id,可选 end_user_id
4.1 第一层:space_id(强制,无例外)
所有业务表强制 space_id 列。数据访问层不依赖上层调用方"自觉传参",而是在基础设施层的拦截器里自动注入:
public class SpaceAwareInterceptor {
void beforeQuery(Query query) {
// 第一层:空间隔离(强制,永不为空)
String spaceId = RequestContext.getCurrentSpaceId();
query.addCondition("space_id = ?", spaceId);
}
}
同时,domain 层的所有 Gateway SPI 接口签名强制携带 spaceId 参数——隔离约束被编码进类型系统:漏传 spaceId 是编译错误,而不是一个深夜爆出的生产事故。
向量检索同样遵守:Milvus 使用 Partition Key 按 space_id 物理分区,检索时强制指定分区。隔离不是只在关系库层面做做样子。
4.2 第二层:end_user_id / end_sub_user_id(可选,两级)
空间隔离解决"哪个空间的数据",但不解决"空间内部哪个终端客户的数据"。典型场景:外部业务系统通过一个 SpaceApiKey 接入平台,它背后有成百上千客户,每个客户有主账号(公司)和子账号(操作员)两级身份,客户的知识库、对话历史、记忆需要按客户维度隔离开。
平台的做法是引入两个不透明字符串:
| 层级 | Header | RequestContext | DB 列 | 语义(由调用方定义) |
|---|---|---|---|---|
| 主账号 | X-End-User-Id |
endUserId |
end_user_id |
客户公司 / 主账号 |
| 子账号 | X-End-Sub-User-Id |
endSubUserId |
end_sub_user_id |
主账号下的操作员 |
平台对这两个值只做三件事:
- 从 HTTP Header 提取 → 注入 RequestContext;
- 写库时随业务数据一起持久化;
- 读库时自动追加
WHERE end_user_id = ?(以及可选的end_sub_user_id = ?)。
拦截器扩展为两级过滤:
public class SpaceAwareInterceptor {
void beforeQuery(Query query) {
// 第一层:强制
query.addCondition("space_id = ?", RequestContext.getCurrentSpaceId());
// 第二层:可选——传入什么就过滤什么,不传入不过滤
String endUserId = RequestContext.getEndUserId();
if (endUserId != null && entityHasColumn("end_user_id")) {
query.addCondition("end_user_id = ?", endUserId);
}
String endSubUserId = RequestContext.getEndSubUserId();
if (endSubUserId != null && entityHasColumn("end_sub_user_id")) {
query.addCondition("end_sub_user_id = ?", endSubUserId);
}
}
}
行为矩阵:
| RequestContext 有值? | 实体有该列? | 结果 |
|---|---|---|
| ✅ | ✅ | 追加 WHERE 条件 |
| ✅ | ❌ | 忽略(该实体不需要这层隔离) |
| ❌ | ✅ | 不过滤,可查空间内全部数据(平台内部使用场景) |
| ❌ | ❌ | 忽略 |
4.3 不同实体,不同隔离粒度
并非所有实体都要两级隔离——按业务语义选择:
| 数据类型 | 隔离键 | 原因 |
|---|---|---|
| 知识库、工具定义、长期记忆 | end_user_id |
公司级资产,主账号下所有子账号共享 |
| 对话历史、反馈 | end_user_id + end_sub_user_id |
个人操作记录,子账号之间互不可见 |
同一空间内:
end_user_id = "cust_A" ─────────────────────────────
│ │
├── KnowledgeBase (按 end_user_id 隔离) │
│ └── 客户 A 的公司知识库,其子账号之间共享 │
│ │
├── SessionMemory (按 end_user_id 隔离) │
│ └── 客户 A 的长期记忆,子账号之间共享 │
│ │
└── ReasoningSession (按 end_sub_user_id 隔离) │
├── 操作员甲的对话历史 │
└── 操作员乙的对话历史 ← 子账号间不可见 │
Redis 缓存的 key 同样带上隔离维度:
// 原来: memory:{sessionId}
// 现在: memory:{spaceId}:{endUserId}:{endSubUserId}:{sessionId}
private String buildKey(String spaceId, String endUserId, String endSubUserId, String sessionId) {
return "memory:" + spaceId + ":"
+ (endUserId != null ? endUserId : "_") + ":"
+ (endSubUserId != null ? endSubUserId : "_") + ":"
+ sessionId;
}
向量检索侧,Milvus 的 filter expression 同步追加,例如 space_id == "ws_abc" && end_user_id == "cust_A"。
调用方通过 Header 组合自由控制粒度:只传 X-End-User-Id → 主账号级隔离(子账号共享);传齐两级 → 子账号级隔离;都不传 → 不隔离(平台内部员工使用时的默认形态)。一套代码,三种粒度,规则只有一句话:传入什么就过滤什么。
5. 信任模型:隔离是数据组织,不是安全边界
这是整个设计里最需要想清楚、也最容易被误用的部分。
┌──────────────────────────────────────────────────────┐
│ 信任边界 │
│ │
│ SpaceApiKey 持有者(外部业务系统后端) │
│ │ │
│ │ ★ 完全信任 — 它能访问该空间内所有数据 │
│ │ │
│ │ 终端用户隔离是"数据组织"而非"安全边界": │
│ │ · 防的是调用方的 bug 导致数据错乱 │
│ │ · 不是防调用方的恶意访问 │
│ │ · Key 持有者可以传任意 end_user_id │
│ │ │
│ ▼ │
│ 调用方的职责: │
│ 1. 校验自己终端客户的身份(它自己的 JWT/Session) │
│ 2. 把已验证的客户 ID 填入 X-End-User-Id │
│ 3. 调用平台 API │
└──────────────────────────────────────────────────────┘
为什么平台侧不校验终端用户身份?
- 平台不拥有调用方的客户数据库,终端用户不存在于平台的任何一张表里;
- 引入校验意味着平台要耦合每个接入方的业务逻辑——接入 N 个系统就要理解 N 套身份体系;
- 正确的分层是:调用方负责身份验证,平台负责数据隔离。如果调用方传了假的
end_user_id,平台无法感知、也不需要感知——那是调用方自己的安全问题。
这个边界的推论很直接:如果某个场景要求"终端用户隔离本身必须是安全边界"(比如终端客户直连平台 API),那 SpaceApiKey 模式就不够用,需要为终端用户引入平台可校验的凭证。我们的取舍是先不做——信任模型必须和使用场景匹配,而不是堆到最厚。
6. 全景图与落地清单
┌──────────────────┐
│ 企业统一身份系统 │ 唯一用户源,验证账号密码
└────────┬─────────┘
│ HTTP 验密(唯一 API)
▼
┌──────────────────────────────────────────────────────────┐
│ Agent 平台 │
│ │
│ /auth/login /auth/switch-space │
│ ① 验身份凭证 ② 校验成员资格,重签 JWT │
│ ③ 返回空间列表 ④ 无需重新验身份 │
│ │ │
│ ▼ │
│ 选定 JWT (user_id + space_id + role) │
│ │ │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ Bearer JWT │ 或 │ X-API-Key │ │
│ │ │ │ (+ X-Space-ID) │ │
│ └──────┬───────┘ └────────┬──────────┘ │
│ │ 认证过滤器 │ │
│ └──────────┬───────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ RequestContext │ │
│ │ · userId (可空) │ │
│ │ · spaceId │ │
│ │ · spaceRole │ │
│ │ · endUserId │ ← X-End-User-Id 透传 │
│ │ · endSubUserId │ ← X-End-Sub-User-Id 透传 │
│ └────────┬─────────┘ │
│ ▼ │
│ Controller → AppService → Gateway → DB │
│ │ │
│ WHERE space_id = ? [+ end_user_id = ? ...] │
└──────────────────────────────────────────────────────────┘
各层职责一句话总结:
| 层 | 职责 |
|---|---|
| Adapter(网关过滤器) | 识别认证方式(JWT / 两种 API Key),提取 Header,注入 RequestContext |
| Application | 成员资格校验、空间内角色判断 |
| Domain | Gateway SPI 签名强制携带 spaceId,编译期保证 |
| Infrastructure | 数据访问拦截器自动注入 WHERE space_id = ? + 可选终端用户条件;Milvus 按 space_id 分区 |
7. 经验总结
- 隔离约束要下沉到类型系统和拦截器,而不是文档和自觉。 SPI 签名带 spaceId 让漏传变成编译错误;拦截器自动注入让"忘了加 WHERE"不可能发生。
- 认证可插拔,上下文统一。 两阶段 JWT 和两种 API Key 最终汇入同一个 RequestContext,授权逻辑只写一份。
- 不认识的实体就用不透明字符串。 终端用户隔离用"透传 + 过滤"实现,平台零耦合接入方的身份体系——这是平台型系统对接外部业务时性价比最高的隔离方案。
- 明确区分"数据组织"与"安全边界",并把信任模型写下来。 模糊的中间态("好像有点安全但又不完全安全")是事故温床。我们的 SpaceApiKey 方案明确选择了前者,并在文档里向所有接入方声明。