feat: complete IM P0 and P1 reliability work
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
# IM 服务端多租户第 5 至 12 章代码改造与测试方案
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文是《IM-服务端多租户改造方案》第 5 章至第 12 章的代码实施方案,覆盖租户识别与认证、Session 与 Topic、Store 与 Adapter、MySQL 数据访问、缓存与集群、文件、搜索,以及 Push、插件和外部服务。
|
||||
|
||||
本文只规划代码改造和测试,不包含数据库建表、数据迁移、历史数据回填、双写、灰度迁移或旧 Token 兼容。实施前提是本地 MySQL 已经更新为最终多租户表结构,所有业务表的 `tenant_id`、复合索引和复合外键均已就绪。
|
||||
|
||||
## 2. 实施范围与原则
|
||||
|
||||
### 2.1 本期范围
|
||||
|
||||
- WebSocket 握手解析企业码并固定 Session TenantID;
|
||||
- 注册、登录、Token、验证码、REST 认证和密码找回租户化;
|
||||
- User、Topic、Subscription、Message、Device、File、Cache 的 Store 与 MySQL 查询租户化;
|
||||
- Hub、本地缓存、Session Store 和集群路由键租户化;
|
||||
- 文件上传、下载、关联、删除和对象存储路径租户化;
|
||||
- 用户、Topic、Tags、消息搜索和组织数据租户化;
|
||||
- Push、Plugin Firehose 及外部服务调用携带可信 TenantID;
|
||||
- 单元测试、MySQL 集成测试、双租户隔离测试和集群测试。
|
||||
|
||||
### 2.2 明确不做
|
||||
|
||||
- 不实现 `tenant_ticket`,首次 `hi` 继续直接提交公开企业码;
|
||||
- 不兼容无 TenantID 的旧 Token;
|
||||
- 不设置默认租户;
|
||||
- 不提供无租户业务 Store;
|
||||
- 不处理历史数据;
|
||||
- 本期产品运行时只支持 MySQL。MongoDB、PostgreSQL 和 RethinkDB 不得以未隔离模式启动。
|
||||
|
||||
### 2.3 核心安全不变量
|
||||
|
||||
以下规则应通过类型、统一入口和测试共同保证:
|
||||
|
||||
```text
|
||||
tenant_id 只能由服务端根据 hi.tenant、已签名 Token 或已认证 Session 得到
|
||||
已认证 Session 的 tenant_id != 0、uid != 0
|
||||
认证结果 tenant_id == Session tenant_id
|
||||
业务对象 tenant_id == Session tenant_id
|
||||
内部消息、缓存键、集群路由和外部调用均携带同一个 tenant_id
|
||||
任何跨租户访问统一返回 NotFound/Denied,不泄露目标对象是否存在
|
||||
```
|
||||
|
||||
禁止从 Topic Tags、请求 Body、URL Query 或普通 Header 中读取客户端自报的内部 `tenant_id` 作为授权依据。
|
||||
|
||||
## 3. 当前实现基线
|
||||
|
||||
| 能力 | 当前状态 | 本方案处理 |
|
||||
| --- | --- | --- |
|
||||
| `TenantID`、租户实体和状态 | 已实现 | 保留并补充通用 TenantKey 类型 |
|
||||
| `hi.tenant` 企业码 | 已实现 | 保留;补充后续链路测试 |
|
||||
| Session 固定 TenantID | 已实现 | 补充认证后用户归属校验 |
|
||||
| MySQL `TenantGetByCode` | 已实现 | 保留 |
|
||||
| `ClientComMessage.TenantID` | 已实现 | 扩展到服务端消息、集群和后台任务 |
|
||||
| protobuf `ClientHi.tenant` | 已实现 | 补充内部 TenantID 字段或 RPC 元数据 |
|
||||
| `AuthContext`、`auth.Rec.TenantID` | 未实现 | 第 5 章实施 |
|
||||
| Token 携带 TenantID | 未实现 | 升级为 Token v2,拒绝旧格式 |
|
||||
| Store/Adapter 业务接口租户化 | 未实现 | 第 7 章实施 |
|
||||
| MySQL 业务 SQL 带租户条件 | 未实现 | 第 8 章实施 |
|
||||
| Hub/Cluster/Cache 路由键租户化 | 未实现 | 第 9 章实施 |
|
||||
| 文件、搜索、Push、插件租户化 | 未实现 | 第 10 至 12 章实施 |
|
||||
|
||||
## 4. 总体代码结构
|
||||
|
||||
### 4.1 租户作用域类型
|
||||
|
||||
在 `server/store/types` 增加统一键类型,所有内部路由和缓存只通过这些类型生成 Key:
|
||||
|
||||
```go
|
||||
type TopicKey struct {
|
||||
TenantID TenantID
|
||||
Topic string
|
||||
}
|
||||
|
||||
func (k TopicKey) RoutingKey() (string, error)
|
||||
|
||||
type UserKey struct {
|
||||
TenantID TenantID
|
||||
UID Uid
|
||||
}
|
||||
|
||||
func (k UserKey) CacheKey() (string, error)
|
||||
```
|
||||
|
||||
`TenantID == ZeroTenantID`、Topic 为空或 UID 为零时生成 Key 必须报错。业务代码不得自行拼接 `tenantID + ":" + topic`。
|
||||
|
||||
### 4.2 租户作用域 Store
|
||||
|
||||
推荐在 `server/store/store.go` 增加:
|
||||
|
||||
```go
|
||||
type TenantStore struct {
|
||||
TenantID types.TenantID
|
||||
Users TenantUsersStore
|
||||
Topics TenantTopicsStore
|
||||
Subs TenantSubsStore
|
||||
Messages TenantMessagesStore
|
||||
Devices TenantDevicesStore
|
||||
Files TenantFilesStore
|
||||
PCache TenantCacheStore
|
||||
}
|
||||
|
||||
func ForTenant(tenantID types.TenantID) (*TenantStore, error)
|
||||
```
|
||||
|
||||
Session、Topic、HTTP Handler 和后台任务先取得 `TenantStore`,再访问业务数据。`ForTenant(0)` 必须返回错误。租户注册表和平台管理操作继续使用独立的 `Tenants`/`PlatformStore`,不能混入业务 Store。
|
||||
|
||||
底层 MySQL Adapter 的业务方法显式接收 `tenantID`。为了避免未完成的数据库 Adapter 继续工作,启动时检查配置的 Adapter 是否完整实现租户能力;非 MySQL Adapter 返回 `ErrUnsupportedTenant` 并终止启动。
|
||||
|
||||
### 4.3 TenantID 的传播方向
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
HI["hi.tenant 企业码"] --> S["Session.tenantID"]
|
||||
S --> A["AuthContext"]
|
||||
A --> R["auth.Rec.TenantID"]
|
||||
S --> C["Client/Server 内部消息"]
|
||||
C --> T["TopicKey / UserKey"]
|
||||
T --> DB["TenantStore / MySQL"]
|
||||
T --> CL["Cluster RPC"]
|
||||
C --> F["File / Search / Push / Plugin"]
|
||||
```
|
||||
|
||||
传播只允许从左向右。认证器、插件、数据库结果和外部回调只能验证 TenantID,不能替换 Session 已绑定的 TenantID。
|
||||
|
||||
## 5. 第 5 章:租户识别与认证流程
|
||||
|
||||
### 5.1 修改目标
|
||||
|
||||
已完成的 `hi.tenant` 解析继续作为连接租户入口。本章补齐认证上下文、认证记录、Token v2、凭证查询和管理员边界。
|
||||
|
||||
### 5.2 代码修改
|
||||
|
||||
#### 5.2.1 认证接口
|
||||
|
||||
修改 `server/auth/auth.go`:
|
||||
|
||||
```go
|
||||
type AuthContext struct {
|
||||
TenantID types.TenantID
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
type Rec struct {
|
||||
TenantID types.TenantID `json:"tenant_id,omitempty"`
|
||||
// 原有字段保持不变
|
||||
}
|
||||
```
|
||||
|
||||
将 `AuthHandler` 的以下方法改为接收 `AuthContext` 或 TenantID:
|
||||
|
||||
```go
|
||||
AddRecord(ctx AuthContext, rec *Rec, secret []byte)
|
||||
UpdateRecord(ctx AuthContext, rec *Rec, secret []byte)
|
||||
Authenticate(ctx AuthContext, secret []byte)
|
||||
IsUnique(ctx AuthContext, secret []byte)
|
||||
DelRecords(tenantID types.TenantID, uid types.Uid)
|
||||
GetResetParams(tenantID types.TenantID, uid types.Uid)
|
||||
```
|
||||
|
||||
同步修改:
|
||||
|
||||
- `server/auth/basic/auth_basic.go`;
|
||||
- `server/auth/code/auth_code.go`;
|
||||
- `server/auth/rest/auth_rest.go`;
|
||||
- `server/auth/token/auth_token.go`;
|
||||
- `server/auth/anon/auth_anon.go`;
|
||||
- `server/auth/mock_auth`;
|
||||
- `server/session.go` 中注册、登录、临时认证和凭证校验调用点;
|
||||
- HTTP 文件认证调用点。
|
||||
|
||||
所有认证器入口先校验 `ctx.TenantID != 0`。认证结果没有 TenantID,或 `rec.TenantID != ctx.TenantID`,统一返回认证失败并记录安全日志。
|
||||
|
||||
#### 5.2.2 Basic、Code、REST 认证
|
||||
|
||||
- Basic 登录名按 `(tenant_id, scheme, unique)` 查询;
|
||||
- 注册时唯一性检查只在当前租户执行;
|
||||
- Code 认证的验证码记录、次数和过期时间按租户隔离;
|
||||
- REST 认证请求携带服务端签名的 TenantID,响应必须原样返回 TenantID;
|
||||
- 密码找回先从 Session/签名上下文得到 TenantID,再查询 Credential,禁止全库按手机号或邮箱查找。
|
||||
|
||||
#### 5.2.3 Token v2
|
||||
|
||||
修改 `server/auth/token/auth_token.go`。当前 Token 是 50 字节且没有版本和 TenantID,替换为明确版本的 v2 布局:
|
||||
|
||||
```text
|
||||
[1:version][8:tenant_id][8:uid][4:expires][2:auth_level]
|
||||
[2:serial][2:features][32:HMAC-SHA256]
|
||||
```
|
||||
|
||||
实施规则:
|
||||
|
||||
- `version` 固定为 2;
|
||||
- `tenant_id` 和其余 Token 字段一起参与 HMAC;
|
||||
- `GenSecret` 要求 `rec.TenantID != 0`;
|
||||
- `Authenticate` 校验版本、精确长度、签名、租户、过期时间和 serial;
|
||||
- v1/当前 50 字节 Token 直接返回 `ErrMalformed` 或 `ErrFailed`;
|
||||
- Token TenantID 与 `AuthContext.TenantID` 不一致时返回认证失败;
|
||||
- 认证成功返回包含 TenantID 的 `auth.Rec`。
|
||||
|
||||
本项目没有历史数据,因此不增加双版本解析和兼容开关。
|
||||
|
||||
#### 5.2.4 用户归属和管理员
|
||||
|
||||
登录取得 UID 后,使用当前 `TenantStore.Users.Get(uid)` 再确认 User 属于相同租户,然后才写入 `Session.uid`。Tenant Admin 角色通过租户内角色/权限数据判断,不能用全局 `AuthLevelRoot` 代替。普通 WebSocket 请求中的 `AsUser` 必须同时验证角色和目标 User 的 TenantID。
|
||||
|
||||
### 5.3 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T5-HI-001 | 单元 | 使用有效且 active 的 `acme` 执行首次 `hi` | Session 固定 Tenant A,响应只含 code/name |
|
||||
| T5-HI-002 | 单元 | `hi` 不传 tenant、传空白或超过 64 字符 | 握手失败,Session 仍未绑定 |
|
||||
| T5-HI-003 | 单元 | 使用不存在或 suspended 租户 | 分别返回 NotFound/Denied,不绑定 Session |
|
||||
| T5-HI-004 | 单元 | 已绑定 A 的 Session 再传 B | 拒绝且 Session 仍为 A |
|
||||
| T5-AUTH-001 | MySQL 集成 | A、B 分别创建相同登录名 `alice` | 两条记录均成功,分别登录到各自用户 |
|
||||
| T5-AUTH-002 | MySQL 集成 | A 使用 B 的 `alice` 密码登录 | 认证失败,不泄露 B 用户存在性 |
|
||||
| T5-AUTH-003 | 单元 | Authenticator 返回 B 的 `auth.Rec` 给 A Session | 登录失败并产生安全日志 |
|
||||
| T5-AUTH-004 | 单元 | 为 A 用户生成 Token v2,再在 A Session 登录 | 成功,TenantID/UID 完整恢复 |
|
||||
| T5-AUTH-005 | 单元 | A Token 在 B Session 使用 | 认证失败,B Session 不写入 UID |
|
||||
| T5-AUTH-006 | 单元 | 修改 Token 中 TenantID 或版本字节 | HMAC/版本校验失败 |
|
||||
| T5-AUTH-007 | 单元 | 使用当前 50 字节旧 Token | 明确拒绝,不进入兼容分支 |
|
||||
| T5-AUTH-008 | MySQL 集成 | A、B 使用相同手机号发起密码找回 | 只命中当前租户用户和验证码 |
|
||||
| T5-AUTH-009 | 权限 | A 的 tenant_admin 代操作 B 用户 | 拒绝;平台管理 API 不受普通 WS 入口影响 |
|
||||
|
||||
## 6. 第 6 章:Session、消息和 Topic 改造
|
||||
|
||||
### 6.1 修改目标
|
||||
|
||||
TenantID 从 Session 贯穿到内部双向消息、Topic 生命周期和所有 P2P/群聊权限检查。客户端 Topic 名和对外协议保持不变。
|
||||
|
||||
### 6.2 代码修改
|
||||
|
||||
#### 6.2.1 内部消息和 Session
|
||||
|
||||
- 保留 `Session.tenantID` 和 `ClientComMessage.TenantID`;
|
||||
- 给 `ServerComMessage` 增加不序列化给普通客户端的 `TenantID`;
|
||||
- Session 收到每条客户端消息时覆盖内部 TenantID,忽略反序列化对象中可能存在的值;
|
||||
- 所有 Topic、Hub、Cluster 和后台生成消息必须显式填写 TenantID;
|
||||
- 登录成功前验证 `User.tenant_id == Session.tenantID`;
|
||||
- PROXY、MULTIPLEX Session 同样必须有 TenantID。
|
||||
|
||||
#### 6.2.2 Topic 对象和路由键
|
||||
|
||||
修改 `server/topic.go`、`server/hub.go`、`server/session.go` 及相关辅助类型:
|
||||
|
||||
- `Topic` 增加初始化后不可变的 `tenantID`;
|
||||
- Hub `topicGet/topicPut/topicDel` 参数从裸字符串改为 `types.TopicKey`;
|
||||
- Session 订阅表、Topic Proxy、Multiplex Session 使用 `RoutingKey()`;
|
||||
- 调试输出可显示 TenantID 和外部 Topic 名,但不能把内部前缀回传客户端;
|
||||
- 数据库中的 Topic 名仍保存原始名称,不保存 `tenantID:` 前缀。
|
||||
|
||||
#### 6.2.3 特殊 Topic、P2P 和群聊
|
||||
|
||||
- `sys` 按 `(tenant_id, "sys")` 懒加载,不再创建全局唯一实例;
|
||||
- `me`、`fnd`、`slf` 使用 Session TenantStore 加载;
|
||||
- 创建 P2P 前加载双方 User,并验证双方与 Session 同租户;
|
||||
- 创建群、邀请、审批、踢人、转让群主、发送系统通知时验证 Topic、操作者和目标成员同租户;
|
||||
- Subscription、Outbox、系统消息和附件关联全部沿用 Topic TenantID。
|
||||
|
||||
### 6.3 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T6-MSG-001 | 单元 | A Session 发送普通消息 | `ClientComMessage.TenantID` 被服务端写为 A |
|
||||
| T6-MSG-002 | 单元 | 构造内部消息自带 B TenantID 后交给 A Session | 值被覆盖或请求被拒绝,不能进入 B |
|
||||
| T6-MSG-003 | 单元 | Topic 产生服务端响应/通知 | `ServerComMessage.TenantID` 等于 Topic TenantID |
|
||||
| T6-TOPIC-001 | 单元 | A、B 同时加载裸名称 `sys` | Hub 中存在两个不同 Topic 实例 |
|
||||
| T6-TOPIC-002 | 单元 | A、B 使用相同 Group Topic 名 | 内部 RoutingKey 不同,对外名称不变 |
|
||||
| T6-P2P-001 | 集成 | A 用户邀请 A 用户创建 P2P | 创建成功,Topic/Subs 均为 A |
|
||||
| T6-P2P-002 | 集成 | A 用户邀请 B 用户创建 P2P | 返回通用 NotFound/Denied,不产生 Topic/Subs |
|
||||
| T6-GRP-001 | 集成 | A 管理员邀请 B 用户加入 A 群 | 拒绝且没有部分写入 |
|
||||
| T6-GRP-002 | 集成 | A 用户用已知 B Topic 名订阅、读、写、删 | 所有操作均失败,B 数据不变化 |
|
||||
| T6-SYS-001 | 集成 | A 发布系统通知 | 只有 A 的 `sys` 订阅者收到 |
|
||||
|
||||
## 7. 第 7 章:Store 与 Adapter 改造
|
||||
|
||||
### 7.1 修改目标
|
||||
|
||||
消除业务代码中的无租户持久化入口,使漏传 TenantID 在编译期或统一 Store 入口立即失败,而不是依赖开发者记得追加 SQL 条件。
|
||||
|
||||
### 7.2 代码修改
|
||||
|
||||
#### 7.2.1 Store 接口
|
||||
|
||||
修改 `server/store/store.go`,为以下域建立 TenantStore Mapper:
|
||||
|
||||
- Users:Create/Get/GetAll/Update/Delete/State/Tags/Topics/Subs/Unread;
|
||||
- Auth:Add/Get/Update/Delete/Unique lookup;
|
||||
- Credentials:Upsert/Get/Confirm/Fail/Delete/Reset lookup;
|
||||
- Topics:Create/Get/Share/Delete/Update/Owner/Find;
|
||||
- Subscriptions:Create/Get/Update/Delete/按 User 或 Topic 查询;
|
||||
- Messages:Save/Get/Delete/GetDeleted/Topic SeqId;
|
||||
- Devices:Upsert/Get/Delete/Push Channel;
|
||||
- Files:Start/Finish/Get/Link/DeleteUnused/Access;
|
||||
- Persistent Cache:Get/Upsert/Delete/Expire。
|
||||
|
||||
现有 `store.Users`、`store.Topics` 等全局 Mapper 不再供 Session/Topic/Handler 使用。完成调用点替换后删除或限制为测试期私有入口,避免新代码绕过 TenantStore。
|
||||
|
||||
#### 7.2.2 Adapter 接口
|
||||
|
||||
修改 `server/db/adapter.go`,所有租户业务方法显式接收 `types.TenantID`,例如:
|
||||
|
||||
```go
|
||||
UserGet(tenantID types.TenantID, uid types.Uid)
|
||||
AuthGetUniqueRecord(tenantID types.TenantID, unique string)
|
||||
TopicGet(tenantID types.TenantID, topic string)
|
||||
SubscriptionGet(tenantID types.TenantID, topic string, uid types.Uid, keepDeleted bool)
|
||||
MessageGetAll(tenantID types.TenantID, topic string, uid types.Uid, opts *types.QueryOpt)
|
||||
FileGet(tenantID types.TenantID, fileID string)
|
||||
```
|
||||
|
||||
数据对象 `User`、`Topic`、`Subscription`、`Message`、`DelMessage`、`Credential`、`DeviceDef` 和 `FileDef` 增加内部 TenantID。Create/Save 时同时校验“方法参数 TenantID、父对象 TenantID、待写对象 TenantID”一致。
|
||||
|
||||
#### 7.2.3 非 MySQL Adapter
|
||||
|
||||
公共接口变化会影响 `server/db/postgres`、`server/db/mongodb`、`server/db/rethinkdb` 和 Mock。处理方式:
|
||||
|
||||
- Mock 完整实现租户参数,用于单元测试;
|
||||
- MySQL 完整实现业务逻辑;
|
||||
- 其他 Adapter 实现相同签名但返回 `ErrUnsupportedTenant`;
|
||||
- 服务启动阶段检测并拒绝使用不支持租户业务接口的 Adapter;
|
||||
- 不允许退回旧的无租户查询。
|
||||
|
||||
### 7.3 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T7-STORE-001 | 单元 | 调用 `store.ForTenant(0)` | 返回错误,不创建 Mapper |
|
||||
| T7-STORE-002 | 单元 | A TenantStore 查询 B UID | 返回 NotFound/Denied |
|
||||
| T7-STORE-003 | 单元 | Create 参数为 A、对象 TenantID 为 B | 写入前失败,Adapter 不被调用 |
|
||||
| T7-STORE-004 | 单元 | Mock 断言 Session 所有 Store 调用 | 每次调用都收到 Session TenantID |
|
||||
| T7-STORE-005 | 构建 | 搜索生产代码中的全局 `store.Users/Topics/...` | Session/Topic/Handler 路径不存在绕过入口 |
|
||||
| T7-ADP-001 | 启动 | 配置 PostgreSQL/MongoDB/RethinkDB | 启动失败并明确提示不支持多租户 |
|
||||
| T7-ADP-002 | 单元 | Adapter 收到零 TenantID | 统一返回 `ErrInvalidTenant`,不执行 SQL |
|
||||
|
||||
## 8. 第 8 章:MySQL 代码改造
|
||||
|
||||
### 8.1 前提
|
||||
|
||||
本地数据库已经更新,本章不执行任何 schema 修改。代码直接以最终多租户表结构为准,测试负责验证 SQL 使用 `tenant_id`、唯一约束和复合外键符合预期。
|
||||
|
||||
### 8.2 代码修改
|
||||
|
||||
修改 `server/db/mysql/adapter.go` 中所有业务 SQL:
|
||||
|
||||
- `SELECT` 必须包含 `WHERE tenant_id = ?`;
|
||||
- `UPDATE`、`DELETE` 必须同时包含 TenantID 和业务主键;
|
||||
- `INSERT` 明确写入 `tenant_id`,不能依赖数据库默认值;
|
||||
- JOIN 同时关联父子表 TenantID,例如 `child.tenant_id = parent.tenant_id`;
|
||||
- 唯一查询按复合业务键执行,如 `(tenant_id, uname)`;
|
||||
- 聚合、未读数、序号、清理任务和批处理同样按租户过滤;
|
||||
- 受影响行数为 0 时直接返回 NotFound/Denied,不再发起无租户探测查询;
|
||||
- 租户级垃圾清理采用 `tenantID + limit`,由平台调度器遍历 active 租户。
|
||||
|
||||
建议按域逐段改造并提交,每段同时更新对应 MySQL 测试:
|
||||
|
||||
1. User、Auth、Credential;
|
||||
2. Topic、Subscription、Tags;
|
||||
3. Message、DelLog、未读数;
|
||||
4. Device;
|
||||
5. FileUpload、FileMessageLink;
|
||||
6. Persistent Cache。
|
||||
|
||||
### 8.3 SQL 审计要求
|
||||
|
||||
新增测试辅助函数或代码审查清单,对 `adapter.go` 中业务 SQL 做静态检查。以下情况必须阻止合并:
|
||||
|
||||
- 业务表查询完全没有 `tenant_id`;
|
||||
- JOIN 只按 ID 关联,没有 TenantID;
|
||||
- Update/Delete 只按全局 ID;
|
||||
- `IN (...)` 批量查询没有 TenantID 前置条件;
|
||||
- 后台清理一次扫描全部租户业务数据;
|
||||
- 为判断“其他租户是否存在该对象”而执行无租户查询。
|
||||
|
||||
### 8.4 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T8-DB-001 | MySQL 集成 | A、B 插入相同 `auth.uname` | 均成功;同租户重复失败 |
|
||||
| T8-DB-002 | MySQL 集成 | A、B 插入相同 Topic name/tag | 均成功;查询只返回当前租户 |
|
||||
| T8-DB-003 | MySQL 集成 | A 查询 B 的 UID、Topic、Message、File | 均返回空/NotFound |
|
||||
| T8-DB-004 | MySQL 集成 | A 更新/删除 B 对象的业务 ID | 影响行数为 0,B 数据不变 |
|
||||
| T8-DB-005 | MySQL 集成 | 尝试写入 A Subscription 关联 B Topic/User | 代码校验或复合外键拒绝,事务回滚 |
|
||||
| T8-DB-006 | MySQL 集成 | A 消息关联 B 文件 | 代码校验或复合外键拒绝 |
|
||||
| T8-DB-007 | MySQL 集成 | A、B 分别统计未读数和设备数 | 结果只包含本租户数据 |
|
||||
| T8-DB-008 | MySQL 集成 | A 的批量删除、过期和垃圾清理 | B 的 Message/File/Cache 不变化 |
|
||||
| T8-DB-009 | 静态审计 | 扫描全部业务 SQL | 无缺失 TenantID 的读写和 JOIN |
|
||||
|
||||
## 9. 第 9 章:缓存与集群改造
|
||||
|
||||
### 9.1 代码修改
|
||||
|
||||
#### 9.1.1 Hub 与 User Cache
|
||||
|
||||
修改 `server/hub.go`:
|
||||
|
||||
- `topicGet/topicPut/topicDel` 使用 `TopicKey.RoutingKey()`;
|
||||
- 放入 Hub 前验证请求、Topic 对象和数据库行 TenantID 一致;
|
||||
- `topicUnreg`、rehash、stats 和 debug 结构增加 TenantID。
|
||||
|
||||
修改 User Cache、未读数、Pending Push 和 EvictUser 相关代码:
|
||||
|
||||
- 使用 `UserKey{TenantID, UID}`;
|
||||
- `EvictUser(tenantID, uid, skipSID)`;
|
||||
- 用户状态、设备和未读数更新都携带 TenantID。
|
||||
|
||||
Session ID 保持全局随机,但通过 SID 取 Session 后,使用方必须读取并验证 Session TenantID,不能再只取 UID。
|
||||
|
||||
#### 9.1.2 集群
|
||||
|
||||
修改 `server/cluster.go`:
|
||||
|
||||
- `ClusterSess`、`ClusterSessUpdate`、`ClusterReq`、`ClusterRoute`、`ClusterResp` 增加 TenantID;
|
||||
- `nodeForTopic`、`isRemoteTopic` 接收 `TopicKey` 并按 RoutingKey 做一致性哈希;
|
||||
- Proxy/Master 两端重算 RoutingKey,并验证 RPC、Session、Client/Server Message TenantID 一致;
|
||||
- User Cache RPC 和 Topic rehash 携带 TenantID;
|
||||
- Cluster 协议版本增加多租户能力位,旧节点拒绝加入。
|
||||
|
||||
#### 9.1.3 持久化缓存
|
||||
|
||||
增加统一 `TenantCacheKey(tenantID, namespace, key)`。租户配置、验证码、未读数和业务临时状态均使用该函数。平台级 schema/version 缓存使用独立 Platform Cache 接口。
|
||||
|
||||
### 9.2 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T9-HUB-001 | 单元 | A、B Put/Get 相同 Topic 名 | 分别命中各自对象,无覆盖 |
|
||||
| T9-HUB-002 | 单元 | 将 B Topic 放入 A TopicKey | 拒绝并记录安全告警 |
|
||||
| T9-USER-001 | 单元 | Evict A 的同值 UID | 只断开 A Session,B Session 保持 |
|
||||
| T9-CACHE-001 | MySQL 集成 | A、B 写同 namespace/key | 值独立;过期 A 不删除 B |
|
||||
| T9-CL-001 | 单元 | A、B 相同 Topic 计算节点 | 使用不同 RoutingKey;结果按各自 Key 稳定 |
|
||||
| T9-CL-002 | 集群集成 | RPC TenantID 与消息 TenantID 不一致 | 接收端拒绝,不创建 Proxy Topic |
|
||||
| T9-CL-003 | 集群集成 | A Session 伪造 B RcptTo | Master 重算路由后拒绝 |
|
||||
| T9-CL-004 | 集群集成 | 节点重哈希 | A、B 同名 Topic 均完整迁移且不合并 |
|
||||
| T9-CL-005 | 启动 | 多租户节点与旧协议节点组网 | 握手失败,不能形成混合版本集群 |
|
||||
|
||||
## 10. 第 10 章:文件与对象存储改造
|
||||
|
||||
### 10.1 代码修改
|
||||
|
||||
在 `server/hdl_files.go` 增加:
|
||||
|
||||
```go
|
||||
type RequestPrincipal struct {
|
||||
TenantID types.TenantID
|
||||
UID types.Uid
|
||||
Roles []string
|
||||
}
|
||||
```
|
||||
|
||||
`authFileRequest` 改为返回 `RequestPrincipal`:
|
||||
|
||||
- Token 路径从 Token v2 得到 TenantID;
|
||||
- SID 路径从 Session 得到 TenantID;
|
||||
- 两条路径都要求 TenantID 和 UID 非零;
|
||||
- API Key 只做应用准入,不推导 TenantID。
|
||||
|
||||
Store/Adapter 增加 `FileCanAccess(tenantID, uid, fileID, action)`,并改造上传、下载、断点续传、关联、删除和垃圾回收:
|
||||
|
||||
- 先使用 TenantID 加载 File;
|
||||
- 未完成上传只允许同租户上传者访问;
|
||||
- 已关联消息时校验同租户 Subscription 和 Read 权限;
|
||||
- 删除验证上传者、群主或租户管理员角色;
|
||||
- 转发时验证源文件、源消息和目标 Topic 同租户;
|
||||
- 签名 URL 覆盖 TenantID、FileID、Action 和 Expires;
|
||||
- 新对象路径使用 `tenants/{tenant_id}/yyyy/mm/{file_id}`;
|
||||
- Location 仅供服务端使用,不直接返回客户端。
|
||||
|
||||
### 10.2 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T10-AUTH-001 | 单元 | Token 和 SID 分别认证同一用户 | 得到相同 TenantID/UID Principal |
|
||||
| T10-AUTH-002 | 单元 | 无租户旧 Token、无效 SID 或零 UID | 认证失败,不进入 File Store |
|
||||
| T10-UP-001 | 集成 | A 上传文件 | DB TenantID 为 A,路径以 `tenants/A/` 开头 |
|
||||
| T10-DL-001 | 集成 | A 用户下载 A 群内有权限文件 | 成功 |
|
||||
| T10-DL-002 | 集成 | A 使用已知 B FileID 下载 | 通用 NotFound/Denied,不返回元数据/Location |
|
||||
| T10-LINK-001 | 集成 | A 消息关联 B FileID | 失败,文件 use_count 不变化 |
|
||||
| T10-FWD-001 | 集成 | A 转发 B 文件到 A Topic | 失败且不创建消息 |
|
||||
| T10-SIGN-001 | 单元 | 修改签名 URL 中 TenantID/FileID/Expires | 验签失败 |
|
||||
| T10-GC-001 | MySQL 集成 | 清理 A 未使用文件 | B 文件记录和对象不被删除 |
|
||||
|
||||
## 11. 第 11 章:搜索、Tags 与组织数据
|
||||
|
||||
### 11.1 代码修改
|
||||
|
||||
修改 `server/store/store.go`、`server/db/adapter.go` 和 `server/db/mysql/adapter.go`:
|
||||
|
||||
- `Find`、`FindOne`、User/Topic Tag 增删改查显式接收 TenantID;
|
||||
- 精确账号、手机号、邮箱查询只在当前租户执行;
|
||||
- 结果对象再次验证 TenantID 后才返回;
|
||||
- 无结果和命中其他租户统一表现为空/NotFound。
|
||||
|
||||
消息搜索索引文档必须包含 TenantID,文档键或路由键使用 `tenant_id + topic + seq_id`。查询时先强制加 TenantID,再按当前用户在同租户 Topic 的 Read ACL 二次过滤。TenantID 来自认证上下文,不接受 Query 参数覆盖。
|
||||
|
||||
组织表 Repository 均以 TenantID 为首参数。新增/移动部门、设置负责人、添加成员时加载父部门和 User,验证它们属于同一租户。涉及多表写入时使用事务,任何归属不一致都整体回滚。
|
||||
|
||||
### 11.2 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T11-FIND-001 | MySQL 集成 | A、B 用户具有相同 `email:`/`tel:` Tag | 各租户只返回自己的用户 |
|
||||
| T11-FIND-002 | MySQL 集成 | A 搜索只有 B 存在的 Tag | 返回空结果,不提示跨租户命中 |
|
||||
| T11-FIND-003 | MySQL 集成 | A、B 使用相同 Topic Tag | 排名和结果集合互不影响 |
|
||||
| T11-MSG-001 | 搜索集成 | A、B 消息正文相同 | A 查询结果中不出现 B 文档 |
|
||||
| T11-MSG-002 | 搜索集成 | A 用户搜索 A 内无 Read 权限 Topic | ACL 二次过滤移除结果 |
|
||||
| T11-MSG-003 | 安全 | Query 参数提交 B TenantID | 参数被忽略或请求被拒绝,仍使用 Token TenantID |
|
||||
| T11-ORG-001 | MySQL 集成 | A 部门添加 A 用户 | 成功,组织关系为 A |
|
||||
| T11-ORG-002 | MySQL 集成 | A 部门添加 B 用户/负责人 | 失败,事务无部分写入 |
|
||||
| T11-ORG-003 | MySQL 集成 | A 部门设置 B 部门为 parent | 失败,部门树不变化 |
|
||||
|
||||
## 12. 第 12 章:Push、插件和外部服务
|
||||
|
||||
### 12.1 Push
|
||||
|
||||
修改 `server/push/push.go`:
|
||||
|
||||
```go
|
||||
type Receipt struct { TenantID types.TenantID /* ... */ }
|
||||
type ChannelReq struct { TenantID types.TenantID /* ... */ }
|
||||
type Payload struct { TenantID types.TenantID `json:"-"` /* ... */ }
|
||||
```
|
||||
|
||||
- Push 构造入口要求 TenantID 非零;
|
||||
- 设备按 `(tenant_id, uid)` 查询;
|
||||
- Channel 使用统一函数生成租户前缀,不能直接拼接客户端值;
|
||||
- Push Handler 根据 TenantID 选择模板、App 包和密钥引用;
|
||||
- 未读数使用 UserKey;
|
||||
- 日志和指标带 TenantID,但不得输出完整设备 Token。
|
||||
|
||||
### 12.2 Plugin Firehose
|
||||
|
||||
修改 `server/plugins.go` 和 protobuf:
|
||||
|
||||
- `pluginGenerateClientReq` 写入 Session TenantID;
|
||||
- Plugin 请求包含 TenantID 和可选租户策略版本;
|
||||
- `REPLACE` 响应反序列化后,服务端强制恢复原 TenantID;
|
||||
- 替换后的目标 Topic 重新执行同租户校验;
|
||||
- `RESPOND` 生成的服务端消息也强制使用原 TenantID;
|
||||
- 插件超时、失败开放/关闭、审计和指标按租户配置执行。
|
||||
|
||||
### 12.3 REST、短信、TURN 和对象存储
|
||||
|
||||
- 外部请求使用服务端签名的 TenantContext,至少覆盖 TenantID、请求用途、过期时间和 nonce;
|
||||
- 回调响应必须返回 TenantID/nonce,服务端验证与原请求一致;
|
||||
- 秘钥管理只保存租户配置中的 Secret 引用,业务 JSON 不保存明文 Secret;
|
||||
- 短信模板、Push 凭证、对象存储 Bucket/Prefix 和 TURN 配置按 TenantID 选择;
|
||||
- 租户没有配置时按明确策略失败,不得静默使用其他租户配置。
|
||||
|
||||
### 12.4 测试用例
|
||||
|
||||
| 编号 | 层级 | 场景与操作 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| T12-PUSH-001 | 单元 | A 消息生成 Push Receipt | Receipt/Payload/Channel 均为 A |
|
||||
| T12-PUSH-002 | 集成 | A、B 有同值 UID 和不同设备 | A Push 只读取并投递 A 设备 |
|
||||
| T12-PUSH-003 | 单元 | A、B 使用相同逻辑 Channel | 生成的物理 Channel 不碰撞 |
|
||||
| T12-PUSH-004 | 单元 | TenantID 为零或找不到租户凭证 | 明确失败,不回退到其他租户凭证 |
|
||||
| T12-PLG-001 | 单元 | A 请求发送到 Firehose | Plugin Request TenantID 为 A |
|
||||
| T12-PLG-002 | 安全 | Plugin `REPLACE` 返回 B TenantID | 服务端拒绝或恢复 A,并重新校验 Topic |
|
||||
| T12-PLG-003 | 安全 | Plugin `RESPOND` 返回 B TenantID | 响应不能进入 B 路由 |
|
||||
| T12-EXT-001 | 单元 | 篡改外部请求 TenantContext | 签名校验失败 |
|
||||
| T12-EXT-002 | 集成 | A 请求收到带 B TenantID/错误 nonce 的回调 | 回调拒绝,不更新任何业务状态 |
|
||||
| T12-EXT-003 | 配置 | A 未配置短信/TURN/对象存储 | 按 A 策略失败,不使用 B 或全局 Secret |
|
||||
|
||||
## 13. 实施顺序与提交边界
|
||||
|
||||
### 阶段 A:基础类型和认证
|
||||
|
||||
1. 增加 TopicKey、UserKey、AuthContext 和 `auth.Rec.TenantID`;
|
||||
2. 修改全部认证器和 Mock;
|
||||
3. 实现 Token v2;
|
||||
4. 完成第 5 章测试。
|
||||
|
||||
完成条件:注册、登录、Token 和密码找回全部绑定租户,旧 Token 明确拒绝。
|
||||
|
||||
### 阶段 B:Store、MySQL 与单机消息链路
|
||||
|
||||
1. 增加 TenantStore;
|
||||
2. 改造 Adapter 接口和 MySQL User/Auth/Credential;
|
||||
3. 改造 Topic/Subscription/Message/Device/File/Cache;
|
||||
4. 改造 Session、Topic 和 Hub 调用点;
|
||||
5. 完成第 6 至 8 章测试。
|
||||
|
||||
完成条件:单机模式下两个租户可使用同名登录名、Topic 和 Tag,所有交叉读写失败。
|
||||
|
||||
### 阶段 C:缓存、集群和文件
|
||||
|
||||
1. Hub/User/PCache Key 租户化;
|
||||
2. Cluster RPC 和一致性哈希租户化;
|
||||
3. 文件 Principal、ACL、路径和签名租户化;
|
||||
4. 完成第 9、10 章测试。
|
||||
|
||||
完成条件:双节点路由和文件链路不存在跨租户缓存命中或资源访问。
|
||||
|
||||
### 阶段 D:搜索、Push、插件和外部服务
|
||||
|
||||
1. Find/Tags/消息索引/组织 Repository;
|
||||
2. Push 数据结构、设备查询和凭证选择;
|
||||
3. Plugin protobuf 与 REPLACE/RESPOND 校验;
|
||||
4. 外部 TenantContext 签名与回调验证;
|
||||
5. 完成第 11、12 章测试。
|
||||
|
||||
完成条件:所有出站和异步链路均可按 TenantID 追踪,并通过反向篡改测试。
|
||||
|
||||
## 14. 测试环境与公共数据
|
||||
|
||||
### 14.1 测试租户
|
||||
|
||||
在专用测试库中准备以下数据,不使用开发人员的日常业务数据:
|
||||
|
||||
| 数据 | Tenant A | Tenant B |
|
||||
| --- | --- | --- |
|
||||
| tenant_id | 1001 | 1002 |
|
||||
| code | acme | beta |
|
||||
| state | active | active |
|
||||
| 登录名 | alice | alice |
|
||||
| 手机号 | 13800000000 | 13800000000 |
|
||||
| 逻辑 Topic | sys、grpShared | sys、grpShared |
|
||||
| Tag | email:alice@example.com | email:alice@example.com |
|
||||
| 文件 | file-shared-name | file-shared-name |
|
||||
|
||||
另外准备一个 `suspended` 租户用于握手和已签 Token 的停用测试。测试数据必须由测试事务或 fixture 创建并清理,不能依赖固定自增 ID。
|
||||
|
||||
### 14.2 测试层次
|
||||
|
||||
- 单元测试:Key、AuthContext、Token、Session、Hub、Cluster RPC、Plugin 和签名;
|
||||
- MySQL 集成测试:每个 Adapter 方法至少包含“同租户成功 + 跨租户失败”;
|
||||
- 服务集成测试:两个真实 WebSocket Session 完成握手、登录、建群、发消息和文件访问;
|
||||
- 双节点集群测试:同名 Topic 路由、Proxy、重哈希和节点版本检查;
|
||||
- 安全测试:篡改 TenantID、Token、RPC、Plugin 响应和外部回调;
|
||||
- 并发测试:A、B 同时创建同名业务键,验证唯一约束只在租户内生效。
|
||||
|
||||
## 15. 验证命令
|
||||
|
||||
具体数据库连接参数沿用仓库现有 MySQL 测试配置,不在文档中写死账号密码。实施过程中至少执行:
|
||||
|
||||
```bash
|
||||
go test ./server/auth/... ./server/store/... ./pbx
|
||||
go test ./server
|
||||
go test -tags mysql ./server/db/mysql/tests
|
||||
go test -race ./server ./server/auth/... ./server/store/...
|
||||
go build -tags mysql ./server/...
|
||||
```
|
||||
|
||||
如果仓库现有 MySQL 测试要求特定工作目录或配置文件,应沿用现有测试入口。集群、对象存储、Push 和外部服务使用本地 fake/mock,另在测试环境执行真实双节点和真实供应商沙箱验收。
|
||||
|
||||
## 16. 完成标准
|
||||
|
||||
- [ ] 第 5 至 12 章所有生产调用链都能追踪到可信 TenantID 来源;
|
||||
- [ ] 已认证 Session、User、Topic、消息和认证结果 TenantID 始终一致;
|
||||
- [ ] 业务 Store 不存在可被生产代码调用的无租户入口;
|
||||
- [ ] MySQL 所有业务读写、JOIN、批处理和清理均包含 TenantID;
|
||||
- [ ] Hub、User Cache、PCache 和 Cluster 路由均使用租户 Key;
|
||||
- [ ] Token、文件签名、Plugin 和外部上下文覆盖 TenantID 并通过篡改测试;
|
||||
- [ ] A/B 双租户同名数据测试全部通过;
|
||||
- [ ] 所有跨租户读、写、邀请、关联、删除和回调测试全部失败且不泄露对象存在性;
|
||||
- [ ] 非 MySQL Adapter 无法以未隔离模式启动;
|
||||
- [ ] 单元测试、MySQL 集成测试、race test 和 MySQL build 全部通过;
|
||||
- [ ] 日志、指标和安全审计含 TenantID,但不输出密码、Token、设备 Token 或租户 Secret。
|
||||
@@ -0,0 +1,932 @@
|
||||
# IM 服务端多租户改造方案
|
||||
|
||||
> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](../IM-独立库多租户改造方案.md)。**
|
||||
>
|
||||
> 新方案下,业务库中的 `users`、`auth`、`topics`、`messages`、`fileuploads` 和后续产品业务表不再增加 `tenant_id` 字段;`TenantID` 仍保留在 Session、Token、Hub、缓存、Push、文件授权、插件上下文和 Store 选库流程中,用于定位租户业务库和运行时隔离。
|
||||
|
||||
> 编写日期:2026-08-10
|
||||
> 适用项目:`im_server` 当前服务端源码
|
||||
> 需求背景:[IM 产品原型需求文档](../IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同”
|
||||
> 关联文档:[IM 产品需求服务端改造分析报告](../IM-产品需求服务端改造分析报告.md)
|
||||
|
||||
> 实施状态:第一阶段租户识别已落地。MySQL Adapter schema 117 创建 `im_tenant`、`im_tenant_config` 和默认租户;首次 `hi` 使用企业码解析并固定 Session TenantID。核心业务表的 `tenant_id`、租户化认证和路由隔离尚未完成。
|
||||
|
||||
## 1. 方案目标
|
||||
|
||||
当前项目是单租户 Tinode 服务端。用户、认证记录、Topic、Subscription、Message、File、缓存、集群路由和 Push 都没有租户维度。本方案目标是在一套服务集群和一套 MySQL 数据库中安全承载多个企业租户,并满足以下约束:
|
||||
|
||||
1. 一个企业的数据默认只能被本企业用户和管理员访问;
|
||||
2. 同一手机号、用户名可以在不同企业分别注册;
|
||||
3. 单聊、群聊、搜索、文件、Push、系统消息和后台任务均不可跨租户;
|
||||
4. 租户身份由服务端在握手/认证阶段确定,客户端后续消息不能修改;
|
||||
5. 现有 `usr...`、`grp...`、`p2p...` Topic 格式尽量保持兼容;
|
||||
6. 多租户启用后,可以按租户配置功能、配额、认证方式、数据保留和安全策略。
|
||||
|
||||
## 2. 当前实现为什么不支持多租户
|
||||
|
||||
### 2.1 认证和 Session 没有租户
|
||||
|
||||
- `auth.Rec` 只有 `Uid`、`AuthLevel`、有效期、状态等字段,没有 `TenantID`;
|
||||
- `Session` 只保存 `uid` 和 `authLvl`,登录成功后直接把认证记录写入 Session;
|
||||
- `MsgClientHi`、`MsgClientLogin` 和 Token 中均没有租户;
|
||||
- basic 登录名在数据库中全局唯一,手机号/邮箱凭证也全局唯一;
|
||||
- 密码找回按 `method:value` 全局查找账号;
|
||||
- root 用户可以通过 `Extra.AsUser` 代表任意用户操作,没有租户范围。
|
||||
|
||||
这意味着同一手机号无法在两个企业分别注册,也无法证明当前连接属于哪个企业。
|
||||
|
||||
### 2.2 数据库全部按全局键查询
|
||||
|
||||
改造前 MySQL schema 版本为 116;第一阶段建立租户主表和握手解析后为 117。除租户主数据表外,核心表仍没有 `tenant_id`:
|
||||
|
||||
| 表 | 当前关键唯一约束 | 多租户问题 |
|
||||
| --- | --- | --- |
|
||||
| `users` | `id` | 无租户归属 |
|
||||
| `auth` | `uname` 全局唯一 | 不同企业不能使用相同登录名 |
|
||||
| `credentials` | `synthetic` 全局唯一 | 同一手机号不能加入多个企业 |
|
||||
| `usertags` | 用户/Tag | 搜索可能返回其他企业用户 |
|
||||
| `devices` | 设备 Hash 全局唯一 | 同一设备上的多企业账号会冲突 |
|
||||
| `topics` | `name` 全局唯一 | 没有 Topic 租户边界 |
|
||||
| `subscriptions` | Topic/User | 不能证明用户与 Topic 属于同一租户 |
|
||||
| `messages` | Topic/SeqId | 查询只按 Topic,没有租户条件 |
|
||||
| `dellog` | Topic/User | 删除记录没有租户条件 |
|
||||
| `fileuploads` | File ID | 文件查询和清理没有租户范围 |
|
||||
| `filemsglinks` | File/Message/Topic/User | 附件关联不能强制同租户 |
|
||||
|
||||
Store/Adapter 的方法也只接收 UID 或 Topic,例如:
|
||||
|
||||
```go
|
||||
UserGet(uid)
|
||||
AuthGetUniqueRecord(unique)
|
||||
TopicGet(topic)
|
||||
SubscriptionGet(topic, uid, keepDeleted)
|
||||
MessageGetAll(topic, uid, opts)
|
||||
FileGet(fileID)
|
||||
```
|
||||
|
||||
只在 SQL 表加字段但不修改这些接口,后续开发很容易漏写 `WHERE tenant_id = ?`,仍然会发生越权。
|
||||
|
||||
### 2.3 内存、集群和系统 Topic 没有租户
|
||||
|
||||
- Hub 的 `topics` 使用裸 Topic 名作为 `sync.Map` Key;
|
||||
- 集群一致性哈希只对裸 Topic 名计算;
|
||||
- `ClusterSess` 和集群 RPC 没有租户字段;
|
||||
- User Cache 仅以 UID 为 Key;
|
||||
- Push Receipt、FCM Channel 只有 UID/Topic,没有租户;
|
||||
- Hub 启动时只初始化一个字面值为 `sys` 的全局系统 Topic;
|
||||
- 持久化缓存 Key 没有租户命名空间。
|
||||
|
||||
如果两个租户都使用 `sys` 等逻辑 Topic,同一个内存对象会被共享。即使普通 Topic 名当前由全局 UID 生成,也不能依赖“ID 不碰撞”代替租户授权。
|
||||
|
||||
### 2.4 文件接口只获得 UID
|
||||
|
||||
`authFileRequest` 当前只返回 UID。文件下载查询只按 File ID,无法把文件访问限制在当前租户。对象存储 Location 也没有统一的租户目录规则。
|
||||
|
||||
## 3. 关键设计决策
|
||||
|
||||
### 3.1 目标模式
|
||||
|
||||
本方案采用:
|
||||
|
||||
> **共享服务集群 + 共享 MySQL 数据库 + 共享表 + 每行 `tenant_id` 强隔离**
|
||||
|
||||
该模式最符合“多企业共用一套平台”的目标,但也是改造面最大的模式。若实际只需要企业数据物理隔离,采用“一企一实例/一企一库”会更简单;它不属于本文的共享多租户实现。
|
||||
|
||||
### 3.2 用户模型:租户内账号
|
||||
|
||||
推荐同一个自然人在不同企业中对应不同的 Tinode User:
|
||||
|
||||
```text
|
||||
手机号 138****0000
|
||||
├─ 租户 A -> user A1 -> 独立好友、群、消息和设置
|
||||
└─ 租户 B -> user B1 -> 独立好友、群、消息和设置
|
||||
```
|
||||
|
||||
这样可以最大程度复用当前 User/Topic/Subscription 模型,离职、停用、数据导出和删除也能按企业处理。
|
||||
|
||||
如果以后需要一个账号切换多个企业,可新增 `global_identities` 和 `tenant_memberships`,把全局身份映射到各租户的本地 User。不要在第一阶段直接把当前 User 改造成跨租户共享主体,否则所有 P2P、资料、设备、未读数和隐私规则都会变得更复杂。
|
||||
|
||||
### 3.3 ID 策略:保持全局唯一,额外校验租户
|
||||
|
||||
继续使用现有 Snowflake/加密 UID 生成器,User ID、File ID、普通 Group Topic 名保持全局唯一。外部协议继续使用现有 `usr...`、`grp...`、`p2p...` 格式,不把租户编码暴露在这些 ID 中。
|
||||
|
||||
所有数据库查询仍必须带 `tenant_id`,原因是:
|
||||
|
||||
- 全局唯一只防止碰撞,不能证明调用者有权访问;
|
||||
- 已知或泄露的其他租户 ID 仍然可能被直接请求;
|
||||
- 使用复合租户条件可以让越权请求稳定返回不存在。
|
||||
|
||||
### 3.4 跨租户通信默认禁止
|
||||
|
||||
第一阶段规则:
|
||||
|
||||
- 用户搜索只在当前租户;
|
||||
- P2P 双方必须属于同一租户;
|
||||
- Group Topic 及全部成员必须属于同一租户;
|
||||
- 文件、收藏、搜索结果和系统通知不跨租户;
|
||||
- Tenant Admin 只能管理本租户;
|
||||
- Platform Admin 的跨租户操作只能走独立后台 API,禁止使用普通客户端协议随意 `AsUser`。
|
||||
|
||||
未来若需要跨企业协作,应作为“外部联系人/联邦通信”独立设计,使用明确授权、双边租户策略和审计,不能绕过本方案的同租户不变量。
|
||||
|
||||
## 4. 租户领域模型
|
||||
|
||||
### 4.1 TenantID 与租户上下文
|
||||
|
||||
新增强类型,不使用裸字符串在各层传递:
|
||||
|
||||
```go
|
||||
package types
|
||||
|
||||
type TenantID int64
|
||||
|
||||
const ZeroTenantID TenantID = 0
|
||||
|
||||
type TenantContext struct {
|
||||
TenantID TenantID
|
||||
ActorUID Uid
|
||||
}
|
||||
|
||||
type TopicKey struct {
|
||||
TenantID TenantID
|
||||
Topic string
|
||||
}
|
||||
|
||||
type UserKey struct {
|
||||
TenantID TenantID
|
||||
UID Uid
|
||||
}
|
||||
```
|
||||
|
||||
`TenantID = 0` 仅允许出现在“尚未完成租户握手”的连接和平台级任务中。任何租户业务 Store 调用发现 `ZeroTenantID` 应立即返回错误,不能自动降级为全局查询。
|
||||
|
||||
`context.Context` 继续用于超时和取消;租户 ID 应作为显式的强类型参数或租户作用域对象,不能只隐藏在 `context.Value` 中。
|
||||
|
||||
### 4.2 租户表
|
||||
|
||||
建议新增:
|
||||
|
||||
```sql
|
||||
CREATE TABLE im_tenant (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
tenant_desc VARCHAR(256),
|
||||
state SMALLINT NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
updated_by BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY tenants_code (code)
|
||||
);
|
||||
```
|
||||
|
||||
`created_by` 和 `updated_by` 保存操作人用户 ID;系统初始化默认租户时使用 `0` 表示系统操作。
|
||||
|
||||
当前阶段不在租户主表预留 `config_version` 和 `data_region`。真正建设租户配置中心或多地域路由时,再通过独立的 `tenant_settings` 表增加相应版本/地域字段,避免提前维护没有实际使用场景的列。
|
||||
|
||||
租户状态建议包括:
|
||||
|
||||
| 状态 | 登录 | 读消息 | 发消息/写业务 | 管理操作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `provisioning` | 禁止 | 禁止 | 禁止 | 平台管理员可配置 |
|
||||
| `active` | 允许 | 允许 | 允许 | 按角色允许 |
|
||||
| `suspended` | 禁止新登录 | 可配置只读 | 禁止 | 平台管理员可恢复/导出 |
|
||||
| `deleting` | 禁止 | 禁止 | 禁止 | 仅执行删除任务 |
|
||||
| `deleted` | 禁止 | 禁止 | 禁止 | 只保留必要审计 |
|
||||
|
||||
### 4.3 租户配置与配额
|
||||
|
||||
第一阶段只建立 `im_tenant_config`,保存用户数、群数和单群成员数上限。每个租户最多一条配置;三个上限字段为 `0` 时表示不限制。
|
||||
|
||||
```sql
|
||||
CREATE TABLE im_tenant_config (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
|
||||
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
|
||||
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
updated_by BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id),
|
||||
CONSTRAINT fk_im_tenant_config_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
`created_by` 和 `updated_by` 保存操作人用户 ID。系统初始化时可以使用约定的系统操作人 ID;在平台管理员账号模型确定前,暂不为这两个字段建立用户外键。
|
||||
|
||||
认证、文件、消息频控、功能开关、保留期、合规策略、第三方服务配置及企业品牌等配置本阶段不建字段,待有明确业务需求后再增加。
|
||||
|
||||
## 5. 租户识别与认证流程
|
||||
|
||||
### 5.1 推荐流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as App
|
||||
participant S as Session
|
||||
participant DB as MySQL
|
||||
|
||||
A->>S: 建立 WebSocket
|
||||
A->>S: hi(tenant_code)
|
||||
S->>DB: 按 code 查询租户
|
||||
DB-->>S: tenant
|
||||
S->>S: 校验 active 并固定 TenantID
|
||||
S-->>A: hi success + 公开租户信息
|
||||
A->>S: login(scheme, secret)
|
||||
```
|
||||
|
||||
第一阶段不引入 Tenant Resolve API 和 `tenant_ticket`。企业码不是密码,客户端可直接提交;服务端必须查询数据库并只接受 `active` 租户,客户端不能直接提交数据库 TenantID。
|
||||
|
||||
`tenant_ticket` 的价值在于把一次企业码解析结果变成服务端签名的短期凭证,防止客户端伪造内部 TenantID,并减少 WebSocket 节点的重复查询;它还可以携带目标集群、过期时间和随机数。当前是单集群且刚开始建设,直接查询更简单。以后出现多集群路由或租户解析查询成为性能瓶颈时,可以再增加 ticket,不影响 Session 固定 TenantID 的核心约束。
|
||||
|
||||
### 5.2 协议修改
|
||||
|
||||
在 `MsgClientHi` 增加:
|
||||
|
||||
```go
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
```
|
||||
|
||||
该字段当前直接传企业码,不传数据库 TenantID。规则如下:
|
||||
|
||||
1. 首次 `hi` 必须设置 Tenant;
|
||||
2. Session 一旦绑定租户,后续 `hi/login/acc/pub/get/set/del/note` 都不能修改;
|
||||
3. 注册、登录和密码找回前必须已绑定租户;
|
||||
4. Tenant 缺失、为空或超过 64 个字符时拒绝握手,不做默认租户兼容;
|
||||
5. 租户不存在时返回 404,租户不是 `active` 状态时返回 403;
|
||||
6. 服务端返回的租户信息仅包含 `code` 和 `name`,不返回内部 ID、状态或密钥。
|
||||
|
||||
当前已实现 `MsgClientHi.Tenant`、MySQL 按企业码查询、Session TenantID 固定、租户公开信息响应,以及未绑定租户时拒绝后续命令。认证器和业务表尚未带 TenantID,因此当前代码只是租户识别基础,不能视为已经完成数据隔离。
|
||||
|
||||
后续 HTTP API 的 TenantID 必须从租户化签名 Token 中获得。禁止只信任 URL、Header 或请求 Body 中客户端自报的 `tenant_id`。
|
||||
|
||||
### 5.3 认证接口修改
|
||||
|
||||
当前认证器接口:
|
||||
|
||||
```go
|
||||
Authenticate(secret []byte, remoteAddr string)
|
||||
```
|
||||
|
||||
建议改为:
|
||||
|
||||
```go
|
||||
type AuthContext struct {
|
||||
TenantID types.TenantID
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
Authenticate(ctx AuthContext, secret []byte)
|
||||
```
|
||||
|
||||
`auth.Rec` 增加:
|
||||
|
||||
```go
|
||||
TenantID types.TenantID `json:"tenant_id,omitempty"`
|
||||
```
|
||||
|
||||
basic、REST、code、token 等认证器和所有 Mock 必须同步修改。认证器返回 TenantID 与 Session 不一致时,统一按认证失败处理,并写安全审计。
|
||||
|
||||
### 5.4 登录名和凭证的租户唯一性
|
||||
|
||||
数据库约束修改为:
|
||||
|
||||
```text
|
||||
auth: UNIQUE (tenant_id, uname)
|
||||
auth: UNIQUE (tenant_id, userid, scheme)
|
||||
credentials: UNIQUE (tenant_id, synthetic)
|
||||
devices: UNIQUE (tenant_id, hash)
|
||||
```
|
||||
|
||||
这样同一手机号可以分别加入多个企业。密码找回必须先有 TenantContext,再按 `(tenant_id, method, value)` 查找,不能全库搜索手机号后选择第一个用户。
|
||||
|
||||
### 5.5 Token v2
|
||||
|
||||
当前 HMAC Token 固定包含 UID、过期时间、认证级别、序列号和特性位,没有 TenantID。需要新增带版本的 Token v2:
|
||||
|
||||
```text
|
||||
[version][tenant_id][uid][expires][auth_level][serial][features][signature]
|
||||
```
|
||||
|
||||
Token 规则:
|
||||
|
||||
1. v2 Token 的签名覆盖 TenantID;
|
||||
2. Token 认证返回 `TenantID + UID`;
|
||||
3. Session 已绑定租户时必须与 Token 一致;
|
||||
4. 不包含 TenantID 的 Token 一律拒绝;
|
||||
5. 租户停用时应支持按租户 Token 版本或撤销时间拒绝已签发 Token,不能只能全平台提升 serial。
|
||||
|
||||
### 5.6 管理员模型
|
||||
|
||||
当前 `AuthLevel` 只有 NONE、ANON、AUTH、ROOT,不足以表达租户管理员。建议保留认证级别,同时增加业务角色:
|
||||
|
||||
- `platform_admin`:平台级租户生命周期和运维;
|
||||
- `tenant_owner`:本租户最高管理员;
|
||||
- `tenant_admin`:组织、用户、群和安全管理;
|
||||
- `security_auditor`:实名、举报、审计读取;
|
||||
- 普通用户。
|
||||
|
||||
Tenant Admin 的权限必须绑定 TenantID。普通 WebSocket 的 `Extra.AsUser` 只能在同租户且有明确授权时使用;平台管理员跨租户代操作应走隔离的后台 API、二次验证和完整审计。
|
||||
|
||||
## 6. Session、消息和 Topic 改造
|
||||
|
||||
### 6.1 Session
|
||||
|
||||
`Session`、`ClusterSess` 增加:
|
||||
|
||||
```go
|
||||
tenantID types.TenantID
|
||||
```
|
||||
|
||||
需要建立以下不变量:
|
||||
|
||||
```text
|
||||
已认证 Session:
|
||||
session.tenantID != 0
|
||||
session.uid != 0
|
||||
User(session.uid).tenant_id == session.tenantID
|
||||
```
|
||||
|
||||
所有内部 `ClientComMessage` 和 `ServerComMessage` 应携带服务端写入的 TenantID,序列化给普通客户端时不必暴露。插件、集群和后台任务可以读取,但不得覆盖。
|
||||
|
||||
### 6.2 内部路由键
|
||||
|
||||
外部 Topic 名保持不变,内部统一使用:
|
||||
|
||||
```go
|
||||
func (k TopicKey) RoutingKey() string {
|
||||
return strconv.FormatUint(uint64(k.TenantID), 10) + ":" + k.Topic
|
||||
}
|
||||
```
|
||||
|
||||
需要修改:
|
||||
|
||||
- Hub `topicGet/topicPut/topicDel`;
|
||||
- Session 的订阅 Map;
|
||||
- Topic Proxy 和 Multiplex Session Key;
|
||||
- 集群一致性哈希 `nodeForTopic/isRemoteTopic`;
|
||||
- ClusterReq、ClusterRoute、ClusterResp;
|
||||
- Topic 重哈希和代理失效;
|
||||
- 调试端点中显示的 Topic 标识。
|
||||
|
||||
客户端仍看到原始 Topic 名,只有服务端内部使用 RoutingKey。不要直接把 `tenantID:` 前缀写入数据库 Topic 名或回传客户端,否则会破坏 Topic 类别解析、P2P 解析和现有客户端。
|
||||
|
||||
### 6.3 `sys`、`me`、`fnd`、`slf`
|
||||
|
||||
- `me`、`fnd`、`slf` 虽由全局 UID 派生,仍必须使用 Session TenantID 加载;
|
||||
- 全局字面值 `sys` 必须改为“租户作用域内的逻辑别名”;
|
||||
- Hub 不再启动一个全局 `sys` Topic;应在租户首次使用或租户创建时初始化 `(tenant_id, sys)`;
|
||||
- 系统消息生产者必须指定 TenantID,不能广播到全平台 `sys`;
|
||||
- `fnd` 搜索只读取当前租户 Tags;
|
||||
- `slf` 收藏不能引用其他租户的源消息或文件。
|
||||
|
||||
### 6.4 P2P
|
||||
|
||||
P2P Topic 名仍由两个全局 UID 生成,但创建前必须执行:
|
||||
|
||||
```text
|
||||
initiator.tenant_id == invited.tenant_id == session.tenant_id
|
||||
```
|
||||
|
||||
订阅、发送、读取、删除和通话时都继续带 TenantID 查询,避免通过已知 P2P 名访问其他企业。
|
||||
|
||||
### 6.5 群聊
|
||||
|
||||
创建 Group Topic 时由 Session 注入 TenantID。邀请、审批、管理员变更和群主转让必须验证:
|
||||
|
||||
- Topic 属于当前租户;
|
||||
- 操作者属于当前租户;
|
||||
- 目标成员属于同一租户;
|
||||
- 所有写入的 Subscription 使用同一 TenantID;
|
||||
- Outbox 和系统通知也使用相同 TenantID。
|
||||
|
||||
不要接受客户端在 `set.desc`、Aux 或 Tags 中提交的 tenant 字段作为授权依据。
|
||||
|
||||
## 7. Store 与 Adapter 改造
|
||||
|
||||
### 7.1 不推荐只靠 `context.Value`
|
||||
|
||||
租户条件应进入编译期可见的接口。推荐增加租户作用域 Store:
|
||||
|
||||
```go
|
||||
tenantStore := store.ForTenant(session.tenantID)
|
||||
|
||||
user, err := tenantStore.Users.Get(uid)
|
||||
topic, err := tenantStore.Topics.Get(topicName)
|
||||
msgs, err := tenantStore.Messages.GetAll(topicName, uid, opts)
|
||||
```
|
||||
|
||||
底层 Adapter 接口显式接收 TenantID:
|
||||
|
||||
```go
|
||||
UserGet(tenantID, uid)
|
||||
AuthGetUniqueRecord(tenantID, unique)
|
||||
TopicGet(tenantID, topic)
|
||||
SubscriptionGet(tenantID, topic, uid, keepDeleted)
|
||||
MessageGetAll(tenantID, topic, uid, opts)
|
||||
FileGet(tenantID, fileID)
|
||||
```
|
||||
|
||||
`store.ForTenant(ZeroTenantID)` 必须失败。平台级扫描和租户生命周期操作使用单独的 `PlatformStore` 接口,不能给业务代码一个可省略 TenantID 的全局 Store。
|
||||
|
||||
### 7.2 需要修改的 Store 接口范围
|
||||
|
||||
| 接口域 | 必须租户化的操作 |
|
||||
| --- | --- |
|
||||
| Users | Create/Get/GetAll/Update/Delete/State/Tags/Topics/Subs/Unread |
|
||||
| Auth | Add/Get/Update/Delete/Unique lookup |
|
||||
| Credentials | Upsert/Get/Confirm/Fail/Delete/Reset lookup |
|
||||
| Topics | Create/Get/Share/Delete/Update/Owner/Find |
|
||||
| Subscriptions | Create/Get/Update/Delete/按 User/Topic 查询 |
|
||||
| Messages | Save/Get/Delete/GetDeleted/更新 Topic SeqId |
|
||||
| Devices | Upsert/Get/Delete/Push Channel |
|
||||
| Files | Start/Finish/Get/Link/DeleteUnused/Access check |
|
||||
| Persistent Cache | Get/Upsert/Delete/Expire |
|
||||
|
||||
这会修改 `server/store/store.go`、`server/db/adapter.go`、MySQL Adapter、Mock Store 以及大量调用点。不要为了减少改动只改少数查询;任何保留的无租户业务接口都会成为后续越权入口。
|
||||
|
||||
### 7.3 数据库支持范围
|
||||
|
||||
当前仓库还有 PostgreSQL、MongoDB、RethinkDB Adapter。多租户改造会使公共 Adapter 接口整体变化。
|
||||
|
||||
推荐产品版第一阶段明确只支持 MySQL:
|
||||
|
||||
- MySQL 完成多租户 schema 和全部集成测试;
|
||||
- 其他 Adapter 编译时可使用明确的 `ErrUnsupportedTenant` 或从产品构建中排除;
|
||||
- 不能让未实现多租户过滤的 Adapter 在配置错误时继续启动;
|
||||
- 如果必须继续支持四种数据库,应将工作量视为四套独立实现和隔离验收。
|
||||
|
||||
## 8. MySQL Schema 改造
|
||||
|
||||
### 8.1 统一规则
|
||||
|
||||
1. 所有租户业务表增加 `tenant_id BIGINT NOT NULL`;
|
||||
2. 唯一索引和高频查询索引以 `tenant_id` 作为第一列;
|
||||
3. 子表使用 `(tenant_id, parent_id)` 复合外键,防止跨租户关联;
|
||||
4. `kvmeta` 中数据库 schema 版本属于平台级元数据,不加 TenantID;
|
||||
5. 持久化业务缓存使用 TenantID 列或强制租户 Key 前缀;
|
||||
6. 所有删除、更新、聚合和后台批处理同样带 TenantID;
|
||||
7. MySQL 没有可直接依赖的应用级 RLS,隔离责任主要在 Adapter 和复合约束。
|
||||
|
||||
### 8.2 核心表与索引
|
||||
|
||||
| 表 | 新增列 | 主要索引调整 |
|
||||
| --- | --- | --- |
|
||||
| `users` | `tenant_id` | `UNIQUE(tenant_id,id)`;状态/最近登录索引前加 tenant |
|
||||
| `usertags` | `tenant_id` | `INDEX(tenant_id,tag)`;`UNIQUE(tenant_id,userid,tag)` |
|
||||
| `devices` | `tenant_id` | `UNIQUE(tenant_id,hash)`;复合外键到 users |
|
||||
| `auth` | `tenant_id` | `UNIQUE(tenant_id,uname)`;`UNIQUE(tenant_id,userid,scheme)` |
|
||||
| `topics` | `tenant_id` | `UNIQUE(tenant_id,name)`;Owner/State 索引前加 tenant |
|
||||
| `topictags` | `tenant_id` | `INDEX(tenant_id,tag)`;`UNIQUE(tenant_id,topic,tag)` |
|
||||
| `subscriptions` | `tenant_id` | `UNIQUE(tenant_id,topic,userid)`;User/Topic 查询前加 tenant |
|
||||
| `messages` | `tenant_id` | `UNIQUE(tenant_id,topic,seqid)`;历史查询索引前加 tenant |
|
||||
| `dellog` | `tenant_id` | Topic/User/DelId 索引前加 tenant |
|
||||
| `credentials` | `tenant_id` | `UNIQUE(tenant_id,synthetic)`;User/Method 索引前加 tenant |
|
||||
| `fileuploads` | `tenant_id` | `INDEX(tenant_id,status,updatedat)`;复合用户归属 |
|
||||
| `filemsglinks` | `tenant_id` | File/Message/Topic/User 索引及复合外键全部带 tenant |
|
||||
|
||||
Topic 名当前通常全局唯一,但仍把数据库唯一约束改为 `(tenant_id, name)`,这样各租户可以拥有自己的逻辑 `sys`,也让 schema 明确表达隔离边界。
|
||||
|
||||
### 8.3 业务表
|
||||
|
||||
后续新增的所有 `im_*` 表,包括好友、群审批、组织、会议、日报、举报、Outbox、审计和消息索引,必须从第一版就包含 TenantID。禁止先以全局表上线再补租户。
|
||||
|
||||
推荐所有 Repository 方法使用如下形态:
|
||||
|
||||
```text
|
||||
Get(tenantID, id)
|
||||
List(tenantID, filter, cursor)
|
||||
Create(tenantID, actorID, data)
|
||||
Update(tenantID, id, expectedVersion, data)
|
||||
Delete(tenantID, id)
|
||||
```
|
||||
|
||||
### 8.4 SQL 示例
|
||||
|
||||
认证查询:
|
||||
|
||||
```sql
|
||||
SELECT userid, secret, expires, authlvl
|
||||
FROM auth
|
||||
WHERE tenant_id = ? AND uname = ?;
|
||||
```
|
||||
|
||||
Topic 查询:
|
||||
|
||||
```sql
|
||||
SELECT ...
|
||||
FROM topics
|
||||
WHERE tenant_id = ? AND name = ? AND state <> ?;
|
||||
```
|
||||
|
||||
消息查询:
|
||||
|
||||
```sql
|
||||
SELECT ...
|
||||
FROM messages
|
||||
WHERE tenant_id = ? AND topic = ? AND seqid < ?
|
||||
ORDER BY seqid DESC
|
||||
LIMIT ?;
|
||||
```
|
||||
|
||||
所有 `UPDATE` 和 `DELETE` 必须同时带业务主键和 `tenant_id`。如果受影响行数为 0,统一返回 NotFound/Denied,不能再用无租户查询判断对象是否属于其他企业,以免泄露对象存在性。
|
||||
|
||||
## 9. 缓存与集群改造
|
||||
|
||||
### 9.1 Hub Topic Cache
|
||||
|
||||
当前 Hub 使用裸 Topic 名作为 Key,应改为 `TopicKey.RoutingKey()`。Topic 对象自身增加 TenantID,初始化后不可修改。
|
||||
|
||||
加载 Topic 时同时检查:
|
||||
|
||||
```text
|
||||
requested tenant == loaded topic tenant == database row tenant
|
||||
```
|
||||
|
||||
如果不一致,记录安全告警并拒绝把 Topic 放入缓存。
|
||||
|
||||
### 9.2 User Cache
|
||||
|
||||
现有 UID 在全平台全局唯一,理论上可以继续只按 UID 缓存。但为避免导入数据、测试固定 UID 或代码误用造成污染,建议改为 `UserKey{TenantID, UID}`。
|
||||
|
||||
`UserCacheReq`、未读数 IO、Pending Push、EvictUser、集群 UserCacheUpdate 全部增加 TenantID。设备注销或账号停用只能影响指定租户的 User。
|
||||
|
||||
### 9.3 Session Store
|
||||
|
||||
Session ID 继续全局随机即可,但以下操作必须带租户断言:
|
||||
|
||||
- 通过 SID 完成文件认证;
|
||||
- `EvictUser`;
|
||||
- Cluster Proxy 恢复 Session;
|
||||
- root/管理员代操作;
|
||||
- 调试页面查看 Session。
|
||||
|
||||
建议新增 `EvictUser(tenantID, uid, skipSID)`,防止固定/导入 UID 冲突时误踢其他企业用户。
|
||||
|
||||
### 9.4 集群路由
|
||||
|
||||
一致性哈希由:
|
||||
|
||||
```text
|
||||
hash(topic)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```text
|
||||
hash(tenant_id + ":" + topic)
|
||||
```
|
||||
|
||||
`ClusterSess`、`ClusterReq`、`ClusterRoute`、`ClusterResp` 和 User Cache RPC 增加 TenantID。接收端验证:
|
||||
|
||||
- RPC 声明租户与消息内部租户一致;
|
||||
- Session 租户与请求租户一致;
|
||||
- RoutingKey 重新计算结果与目标节点一致;
|
||||
- Plugin 返回的替换消息不能修改租户。
|
||||
|
||||
集群节点加入时必须校验协议版本,不理解 Tenant 字段的节点应拒绝加入集群。
|
||||
|
||||
### 9.5 持久化缓存
|
||||
|
||||
缓存 Key 统一通过函数生成:
|
||||
|
||||
```go
|
||||
TenantCacheKey(tenantID, namespace, key)
|
||||
```
|
||||
|
||||
禁止业务代码自行字符串拼接。租户配置变化时只失效该租户 Key。平台级 schema/version Key 与租户业务 Cache 使用不同接口。
|
||||
|
||||
## 10. 文件与对象存储改造
|
||||
|
||||
### 10.1 HTTP 认证结果
|
||||
|
||||
`authFileRequest` 从只返回 UID 改为:
|
||||
|
||||
```go
|
||||
type RequestPrincipal struct {
|
||||
TenantID types.TenantID
|
||||
UID types.Uid
|
||||
Roles []string
|
||||
}
|
||||
```
|
||||
|
||||
Token 认证和 SID 认证都必须得到相同 Principal。API Key 只表示合法客户端应用,不能替代 Tenant 身份和资源授权。
|
||||
|
||||
### 10.2 文件访问
|
||||
|
||||
新增:
|
||||
|
||||
```go
|
||||
FileCanAccess(tenantID, uid, fileID, action)
|
||||
```
|
||||
|
||||
访问流程:
|
||||
|
||||
1. `FileGet(tenantID, fileID)`;
|
||||
2. 上传未完成时只允许同租户上传者访问;
|
||||
3. 已关联消息时,Subscription 必须为同租户且有 Topic Read 权限;
|
||||
4. 群文件删除检查同租户上传者/管理员/群主;
|
||||
5. 转发时源文件、源消息和目标 Topic 全部属于同租户;
|
||||
6. 生成短时签名 URL,签名上下文包含 TenantID/FileID/过期时间;
|
||||
7. 审计日志记录 TenantID、UID、FileID 和结果。
|
||||
|
||||
### 10.3 存储路径
|
||||
|
||||
新文件统一使用:
|
||||
|
||||
```text
|
||||
tenants/{tenant_id}/yyyy/mm/{file_id}
|
||||
```
|
||||
|
||||
Location 不直接返回客户端。垃圾回收任务按 TenantID 分批并设置公平配额,避免大租户占满清理队列。
|
||||
|
||||
## 11. 搜索、Tags 与组织数据
|
||||
|
||||
### 11.1 用户和 Topic 搜索
|
||||
|
||||
`Find`、`FindOne`、Tag 更新和精确账号搜索都必须带 TenantID。相同手机号/用户名在不同租户生成相同 Tag 没有问题,因为索引和唯一约束均以 TenantID 为前缀。
|
||||
|
||||
跨租户查询统一返回空结果或通用 NotFound,不能返回“该用户属于其他企业”。
|
||||
|
||||
### 11.2 消息搜索
|
||||
|
||||
消息索引记录必须包含 TenantID,搜索引擎索引名或文档路由也包含 TenantID:
|
||||
|
||||
```text
|
||||
tenant_id + topic + seq_id
|
||||
```
|
||||
|
||||
查询至少经过两层过滤:
|
||||
|
||||
1. 搜索请求强制加入 TenantID;
|
||||
2. 返回结果按当前用户的同租户 Topic Read ACL 二次校验。
|
||||
|
||||
如果使用 OpenSearch/Elasticsearch,可以共享索引并按 TenantID 路由,也可以按大租户独立索引。无论哪种方式,TenantID 必须来自 Token,而不是客户端 Query 参数。
|
||||
|
||||
### 11.3 组织架构
|
||||
|
||||
`im_org_units`、`im_org_members` 等第 9 章业务表以 TenantID 为根。部门树的 `parent_id`、负责人、成员和数据权限都必须使用复合租户约束,禁止把其他租户 User 设置为部门负责人。
|
||||
|
||||
## 12. Push、插件和外部服务
|
||||
|
||||
### 12.1 Push
|
||||
|
||||
`push.Receipt`、`ChannelReq` 和 `Payload` 内部增加 TenantID。要求:
|
||||
|
||||
- 设备查询使用 `(tenant_id, uid)`;
|
||||
- FCM Topic/Channel 名加入不可碰撞的租户前缀;
|
||||
- 租户级 Push 模板、应用包和凭证由服务端配置选择;
|
||||
- Push Handler 日志不可输出其他租户的设备 Token;
|
||||
- 未读数 Cache 使用 Tenant User Key;
|
||||
- 锁屏正文按租户安全策略生成。
|
||||
|
||||
如果不同租户使用不同品牌 App,Push Handler 需要根据 TenantID 选择 FCM/APNS 凭证,不能继续假定一套全局凭证。
|
||||
|
||||
### 12.2 Plugin Firehose
|
||||
|
||||
Plugin 请求增加 TenantID、租户策略版本和不可伪造的上下文。插件返回 `REPLACE` 时服务端必须保留原 TenantID,禁止插件把消息换到其他租户/Topic。
|
||||
|
||||
插件配置分两类:
|
||||
|
||||
- 平台级统一审核服务;
|
||||
- 租户级专属审核服务。
|
||||
|
||||
无论哪种,调用超时、熔断、失败开放/关闭策略都按租户配置,审计和指标带 TenantID。
|
||||
|
||||
### 12.3 REST 认证、短信、TURN 和对象存储
|
||||
|
||||
外部请求必须携带服务端签名的租户上下文,并对回调结果校验 TenantID。租户自带密钥时,通过密钥管理系统按 TenantID 选择,不把 Secret 缓存在普通 Topic/User JSON 中。
|
||||
|
||||
## 13. API Key、限流和配额
|
||||
|
||||
当前 `X-Tinode-APIKey` 是应用级准入凭证。多租户后建议保持以下职责:
|
||||
|
||||
- API Key:识别客户端应用、平台、版本和是否允许连接;
|
||||
- Tenant Ticket/Token:确定租户;
|
||||
- User Token:确定租户内用户;
|
||||
- Role/Policy:确定具体操作权限。
|
||||
|
||||
不要仅通过 API Key 推导 Tenant,因为同一个 App 可能登录多个企业。若某企业使用专属 App,可额外把 API Key 限定到一个 Tenant,但仍需验证 Token Tenant 一致。
|
||||
|
||||
限流 Key 至少包含:
|
||||
|
||||
```text
|
||||
tenant + endpoint/action
|
||||
tenant + uid + action
|
||||
tenant + ip + action
|
||||
```
|
||||
|
||||
配额计数包括用户、Topic、消息速率、文件大小、总存储、会议/报告等。超限返回稳定业务错误并记录租户审计,不能让一个大租户耗尽全平台队列。
|
||||
|
||||
## 14. 日志、指标与审计
|
||||
|
||||
### 14.1 日志和 Trace
|
||||
|
||||
认证后所有请求日志增加:
|
||||
|
||||
```text
|
||||
request_id, tenant_id, uid, sid, topic, action, result
|
||||
```
|
||||
|
||||
手机号、证件号、Token、企业码密钥和文件签名不写日志。TenantID 可写内部数值或不可逆短标识。
|
||||
|
||||
### 14.2 指标
|
||||
|
||||
租户 ID 直接作为 Prometheus Label 可能造成高基数。建议:
|
||||
|
||||
- 平台指标默认不带 Tenant Label;
|
||||
- 关键配额和 SLA 写入租户用量表或专用统计系统;
|
||||
- 仅对白名单大租户暴露租户级指标;
|
||||
- 错误日志和 Trace 保留 TenantID 供定位。
|
||||
|
||||
### 14.3 审计
|
||||
|
||||
以下操作必须记录 TenantID:
|
||||
|
||||
- 租户创建、停用、恢复和删除;
|
||||
- Tenant Admin 授权和代操作;
|
||||
- 用户停用、离职和数据导出;
|
||||
- 群主/管理员变更;
|
||||
- 实名审核、举报处置、匿名信箱读取;
|
||||
- 文件下载、批量导出和搜索敏感内容;
|
||||
- 平台管理员跨租户操作。
|
||||
|
||||
审计表只允许追加,普通租户管理员不能修改或删除。
|
||||
|
||||
## 15. 测试方案
|
||||
|
||||
### 15.1 单元和接口测试
|
||||
|
||||
- TenantContext 不可为空;
|
||||
- Session 只能绑定一次 Tenant;
|
||||
- Token Tenant 与 Session Tenant 不一致时登录失败;
|
||||
- Store/Adapter 不提供无 Tenant 的业务查询;
|
||||
- TopicKey、UserKey、CacheKey 和 Push Channel 正确命名空间化;
|
||||
- Plugin 不能替换 Tenant;
|
||||
- Platform Admin 与 Tenant Admin 权限边界正确。
|
||||
|
||||
### 15.2 MySQL 集成测试
|
||||
|
||||
创建 Tenant A、Tenant B,并使用相同用户名、手机号、群显示名和逻辑 `sys`。至少覆盖:
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| 两租户注册相同用户名/手机号 | 均成功,各自登录到不同 UID |
|
||||
| A 使用 B 的账号密码登录 | 统一认证失败 |
|
||||
| A 已知 B 的 UID 执行 Get/Set/Delete | NotFound/Denied,不返回存在性 |
|
||||
| A 已知 B 的 Group Topic 订阅或发布 | 拒绝且不加载到 A 的 Hub Cache |
|
||||
| A 邀请 B 用户进入 A 群 | 拒绝,无 Subscription 写入 |
|
||||
| A 已知 B 的 Message Topic/SeqId | 不能读取、搜索、删除或收藏 |
|
||||
| A 已知 B 的 File ID/URL | 不能下载、关联或转发 |
|
||||
| A/B 同时使用 `sys` | 只收到本租户系统消息 |
|
||||
| A 停用用户 | 不影响相同设备上的 B 用户 |
|
||||
| A Tenant Admin `AsUser` B 用户 | 拒绝并审计 |
|
||||
| 同一设备在 A/B 注册 Push | 设备记录和 Push 不串租户 |
|
||||
| A 大量任务积压 | B 的通知和文件 GC 不被长期饿死 |
|
||||
|
||||
### 15.3 集群测试
|
||||
|
||||
- 相同 Topic 别名在不同 Tenant 生成不同 RoutingKey;
|
||||
- Proxy/Master 传输后 Tenant 不丢失;
|
||||
- 节点重启、重哈希和故障转移不串 Topic;
|
||||
- 混入旧节点时启动检查直接失败;
|
||||
- User Cache、未读数和 Push 在远程节点保持租户一致。
|
||||
|
||||
### 15.4 安全测试
|
||||
|
||||
- 篡改 `hi.tenant`、Token Tenant、HTTP Header 和 Body Tenant;
|
||||
- 枚举其他租户 UID、Topic、File ID、企业码;
|
||||
- SQL 查询遗漏 Tenant 的代码扫描与集成测试;
|
||||
- root/OBO 越权;
|
||||
- 搜索索引、缓存、导出、日志和审计的跨租户泄露;
|
||||
- 对象存储签名 URL 重放和跨租户替换;
|
||||
- Plugin/回调伪造 Tenant。
|
||||
|
||||
## 16. 代码修改清单
|
||||
|
||||
| 文件/目录 | 主要修改 |
|
||||
| --- | --- |
|
||||
| `server/store/types/tenant.go`、`types.go` | TenantID、TenantContext、TopicKey、UserKey;核心实体 TenantID |
|
||||
| `server/auth/auth.go` | AuthContext、`auth.Rec.TenantID`、认证接口 |
|
||||
| `server/auth/basic` | 租户内登录名查询和唯一性 |
|
||||
| `server/auth/token` | Token v2、TenantID 和租户撤销版本 |
|
||||
| `server/auth/rest`、`server/auth/code` | 传递并校验租户上下文 |
|
||||
| `server/datamodel.go` | `MsgClientHi.Tenant`;内部消息 TenantID |
|
||||
| `server/session.go` | Tenant 握手、固定 Session Tenant、登录/注册/发布断言 |
|
||||
| `server/user.go` | User Cache Key、租户级未读/踢出/状态更新 |
|
||||
| `server/sessionstore.go` | `EvictUser(tenant, uid)`、SID 认证租户断言 |
|
||||
| `server/hub.go` | TopicKey 缓存、租户 `sys`、租户路由 |
|
||||
| `server/init_topic.go` | Topic Tenant 初始化和同租户检查 |
|
||||
| `server/topic.go` | Topic/成员/消息 Tenant 不变量 |
|
||||
| `server/cluster.go`、`server/topic_proxy.go` | Cluster RPC Tenant、复合 RoutingKey、协议版本检查 |
|
||||
| `server/store/store.go` | `ForTenant` 作用域和所有 Mapper 租户化 |
|
||||
| `server/db/adapter.go` | Adapter 全接口 Tenant 参数 |
|
||||
| `server/db/mysql/adapter.go` | 所有 SQL Tenant 条件和复合约束 |
|
||||
| `server/db/mysql/schema.sql` | im_tenant、tenant_id、索引、外键和新 schema 版本 |
|
||||
| `server/hdl_files.go` | RequestPrincipal、FileCanAccess、Tenant 文件路径 |
|
||||
| `server/push.go`、`server/push` | Receipt/Channel/设备查询 Tenant 化 |
|
||||
| `server/plugins.go`、`pbx` | 插件协议 Tenant 上下文和不可变校验 |
|
||||
| `server/main.go` | 租户配置缓存和启动自检 |
|
||||
| `server/tinode.conf` | 租户服务和配额配置 |
|
||||
| `server/store/mock_store` | 重新生成所有租户化 Mock |
|
||||
| `server/*_test.go`、`server/db/mysql/tests` | 双租户正向、负向和隔离测试 |
|
||||
|
||||
## 17. 实施阶段与交付物
|
||||
|
||||
### 阶段 0:决策和基线
|
||||
|
||||
- 确认共享表模式;
|
||||
- 确认租户内独立 User;
|
||||
- 确认跨租户通信首期禁止;
|
||||
- 确认产品版数据库支持范围;
|
||||
- 冻结 TenantID、TopicKey 和认证协议设计。
|
||||
|
||||
交付物:架构决策记录、数据字典和接口清单。
|
||||
|
||||
### 阶段 1:Schema 与租户基础服务
|
||||
|
||||
- `im_tenant`、`im_tenant_config`;
|
||||
- 核心表 `tenant_id BIGINT NOT NULL`;
|
||||
- 默认租户;
|
||||
- `hi.tenant` 企业码解析和 Session 绑定;
|
||||
- 租户配置缓存。
|
||||
|
||||
交付物:全新数据库初始化 Schema、租户基础服务和 Schema 约束测试。
|
||||
|
||||
### 阶段 2:认证和 Store
|
||||
|
||||
- Session Tenant;
|
||||
- AuthContext 和 Token v2;
|
||||
- `store.ForTenant`;
|
||||
- MySQL 全查询租户化;
|
||||
- Mock 和单元测试更新。
|
||||
|
||||
交付物:租户内注册、登录和 Store 隔离能力。
|
||||
|
||||
### 阶段 3:实时路由与集群
|
||||
|
||||
- Hub TopicKey;
|
||||
- 租户 `sys/me/fnd/slf`;
|
||||
- Cluster RPC 和一致性哈希;
|
||||
- User Cache 和 Session Evict;
|
||||
- Plugin Tenant。
|
||||
|
||||
交付物:双租户集群隔离测试通过,不兼容节点无法加入集群。
|
||||
|
||||
### 阶段 4:文件、Push、搜索和后台任务
|
||||
|
||||
- 文件 Principal、ACL 和租户路径;
|
||||
- Push Receipt/设备/凭证租户化;
|
||||
- 搜索索引和二次 ACL;
|
||||
- Outbox、审计、GC、配额和限流租户化。
|
||||
|
||||
交付物:资源和异步链路跨租户负向测试通过。
|
||||
|
||||
### 阶段 5:上线验收
|
||||
|
||||
- 内部第二租户;
|
||||
- 安全测试、压测和故障演练;
|
||||
- 上线检查和真实第二企业启用。
|
||||
|
||||
交付物:测试报告、应急预案和多租户上线审批。
|
||||
|
||||
## 18. 主要风险
|
||||
|
||||
| 风险 | 影响 | 控制措施 |
|
||||
| --- | --- | --- |
|
||||
| 某个 SQL 漏 Tenant 条件 | 严重数据泄露 | 显式 Store 接口、复合约束、双租户负向测试和代码检查 |
|
||||
| 只改数据库,缓存/集群未改 | 实时消息串租户 | TopicKey/UserKey、Cluster Tenant 不变量和集群测试 |
|
||||
| 全局 `sys` 未拆分 | 系统通知泄露 | 租户逻辑 sys、Hub 复合 Key、通知生产者强制 Tenant |
|
||||
| 文件 URL 泄露 | 跨租户下载 | FileGet Tenant 条件、Topic ACL、短时签名和审计 |
|
||||
| root/OBO 权限过大 | 管理员跨租户操作 | Platform/Tenant Admin 分离、后台 API、二次验证和审计 |
|
||||
| 混跑旧集群节点 | Tenant 字段丢失 | 集群协议版本检查,不兼容节点拒绝加入 |
|
||||
| 指标 Tenant Label 高基数 | 监控系统压力 | 指标聚合,Tenant 主要进入日志/Trace/用量表 |
|
||||
|
||||
## 19. 上线前必须满足的条件
|
||||
|
||||
1. 业务代码不存在无 Tenant 的 User/Topic/Message/File 查询接口;
|
||||
2. 核心表和全部业务表 TenantID 为 NOT NULL;
|
||||
3. Auth、Credential、Device、Topic 和 Subscription 唯一约束已租户化;
|
||||
4. Token v2、Session Tenant 和 HTTP Principal 全链路一致;
|
||||
5. Hub、User Cache、Cluster、Push、Plugin 和 Persistent Cache 均携带 Tenant;
|
||||
6. `sys` 已按租户隔离;
|
||||
7. 文件下载和搜索完成租户过滤及 ACL 二次校验;
|
||||
8. 双租户自动化测试、集群测试和安全测试全部通过;
|
||||
9. 所有运行节点和后台任务均为租户感知版本;
|
||||
10. 内部第二租户稳定运行后,才允许创建真实第二企业。
|
||||
|
||||
## 20. 最终建议
|
||||
|
||||
多租户改造必须被视为服务端基础架构改造,而不是“给 users/topics/messages 加一列”。当前代码的认证、Store 接口、Hub Cache、Cluster、系统 Topic、文件和 Push 都使用全局 UID/Topic 语义,任何一处遗漏都可能造成跨企业数据泄露。
|
||||
|
||||
建议采用租户内独立 User、全局唯一 UID、共享表 `tenant_id` 强隔离的方案。全新数据库从创建时就使用最终多租户 Schema,并在 Store、认证、Token、路由、缓存、文件和 Push 全链路强制 TenantID;所有负向测试通过后,再开启真实租户。
|
||||
|
||||
工程上最重要的控制手段是:让 TenantID 进入类型和方法签名,使遗漏租户条件尽量在编译或测试阶段暴露;平台级全局查询使用隔离接口,普通业务代码永远只能获得 `store.ForTenant(...)`。这样才能让后续好友、群治理、企业工作台、搜索和安全业务从一开始就在正确的隔离边界内建设。
|
||||
Reference in New Issue
Block a user