我们的内部 Agent 能力平台同时服务两类调用方:坐在浏览器前的员工,和背后有成百上千终端客户的外部业务系统(通过 API Key 接入,让 Agent 能力嵌入它们自己的产品)。这两类调用方对"隔离"的要求完全不同:前者是典型的多租户 SaaS 问题,后者要求平台在空间内部再切一层"终端用户"维度的数据隔离——而平台本身并不认识这些终端用户。

这篇文章完整记录我们落地的三层隔离模型:

  1. 工作空间隔离(强制)space_id 是一切数据隔离的基本单元;
  2. 两阶段 JWT + 双模式 API Key:交互式与自动化两种认证通道如何统一汇入同一个请求上下文;
  3. 终端用户透传隔离(可选)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}                │                            │
  │ ←───────────────────────── │                            │
  • 预选 JWTselected_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
}

两个要点:

  1. JWT 是平台签发的,不是身份系统签发的。 身份系统只回答"这个人是谁","他能进哪些空间、是什么角色"是平台自己的授权决策。职责切分干净。
  2. 切换空间不需要重新登录。 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 主账号下的操作员

平台对这两个值只做三件事:

  1. 从 HTTP Header 提取 → 注入 RequestContext;
  2. 写库时随业务数据一起持久化;
  3. 读库时自动追加 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. 经验总结

  1. 隔离约束要下沉到类型系统和拦截器,而不是文档和自觉。 SPI 签名带 spaceId 让漏传变成编译错误;拦截器自动注入让"忘了加 WHERE"不可能发生。
  2. 认证可插拔,上下文统一。 两阶段 JWT 和两种 API Key 最终汇入同一个 RequestContext,授权逻辑只写一份。
  3. 不认识的实体就用不透明字符串。 终端用户隔离用"透传 + 过滤"实现,平台零耦合接入方的身份体系——这是平台型系统对接外部业务时性价比最高的隔离方案。
  4. 明确区分"数据组织"与"安全边界",并把信任模型写下来。 模糊的中间态("好像有点安全但又不完全安全")是事故温床。我们的 SpaceApiKey 方案明确选择了前者,并在文档里向所有接入方声明。