KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 多租户身份体系:user / member / tenant 三级 id 的分工 — keel 龙骨
单租户系统里,"用户"就是一个人。多租户系统里不是:同一个人在不同租户里是不同的身份,角色、数据范围、可见资源全都不同。这一章讲清三级 id 各自的职责,以及"客户端传来的租户 id 为什么只能当上下文选择器"这条最容易被误解的规则。
单租户系统里,"用户"就是一个人。多租户系统里不是:同一个人在不同租户里是不同的身份,角色、数据范围、可见资源全都不同。这一章讲清三级 id 各自的职责,以及"客户端传来的租户 id 为什么只能当上下文选择器"这条最容易被误解的规则。
一、现场:一个人在一个平台是管理员,在另一个平台是普通用户
一个既是"企业版客户"的员工又是"个人版用户"的技术人员,用同一个账号登录两个租户:
- 在租户 A(他供职的公司)里,他是管理员,能删用户、能看全公司数据;
- 在租户 B(他自己开的个人空间)里,他只是访客,只能看自己。
系统出问题的表现是:在租户 B 里也拿到了租户 A 的管理员权限。
根因是系统只有一个用户表和一个角色关联表,角色没有租户维度——或者有,但判定时忘了带租户条件。
二、概念边界:三级 id 各自的职责
user_id 全局自然人主键,跨租户不变 —— "你是谁"(账号层面的唯一标识)
tenant_id 租户主键 —— "你在哪个空间里"(隔离边界)
member_id 某人在某个租户内的身份主键 —— "你在这个空间里是谁"(权限真正挂载点)
最关键的一句:权限、角色、数据范围、可见资源,全部挂在 member_id 上,不挂 user_id。
配套项目的 member 表把这个事实固化了:
CREATE TABLE member (
id TEXT PRIMARY KEY, -- 租户内身份主键,业务代码里用的是它
tenant_id TEXT NOT NULL REFERENCES tenant(id),
org_id TEXT NOT NULL REFERENCES org(id),
user_id TEXT NOT NULL REFERENCES app_user(id),
status TEXT NOT NULL DEFAULT 'active',
UNIQUE (tenant_id, user_id) -- 一个人在同一个租户里只有一个身份
);
UNIQUE (tenant_id, user_id) 这个约束是整套设计的锚点:它保证"身份"在租户内唯一,
从而让 (member_id, ...) 成为所有业务表外键的稳定目标。
为什么不能省掉 member 这一层
一种偷懒做法是:业务表直接存 user_id,靠查 user 表时 join 出租户。问题有三个:
- 角色挂不上:
user_role里放user_id,同一个人在 A 租户的管理员角色会泄漏到 B 租户; - 租户内属性无处安放:
org_id(部门)、入职状态、租户内昵称都跟着租户走,挂到全局用户上就乱了; - 切换租户要改数据:从"改 user 的租户字段"变成"换一个身份上下文",后者才是正确的模型。
真实项目里还有一个常见的中层:组织内的一个成员可能有多个身份(既是 A 部门的普通成员,
又是某个虚拟团队的负责人)。这就需要 workspace_member 这样的中间表(配套项目里就有),
它表达的是"谁在这个空间里",与"谁在这个租户里"是两个不同的问题。
三、一次完整运行:同一个账号在两个租户
配套项目 python demo.py 的实测输出:
=== 03 多级 id:user → member → 租户内身份 ===
u_alice @ t_acme -> member=m_alice_acme roles=('tenant_admin',)
u_alice @ t_globex -> member=m_alice_globex roles=('viewer',)
u_bob @ t_globex -> TenantMismatch: not_a_member_of_tenant
三行读出来是三个结论:
- 同一个
u_alice,两个租户拿到两个不同的member_id——这就是多租户身份体系的全部意义; - 角色跟着身份走,不是跟着人走:
tenant_admin只在 acme 有效; - 不是该租户成员 → 直接拒绝,且拒绝原因统一为
not_a_member_of_tenant(不区分"用户不存在"和"存在但不属于此租户",见第 05 章)。
对应的解析逻辑只有一条查询(project/identity.py):
member = one(conn, """
SELECT id, tenant_id, org_id, status
FROM member
WHERE user_id = ? AND tenant_id = ? -- ← 两个条件缺一不可
""", (user_id, tenant_id))
if member is None:
raise TenantMismatch()
注意这条 SQL 里的 tenant_id 条件。漏掉它,就能在租户 A 的请求里把同一个人在租户 B 的身份取出来——
这是多租户系统最典型的一处漏洞,而且代码看起来完全正常(只是少了一个条件)。
四、客户端传来的租户 id:上下文选择器,不是凭据
真实系统里,客户端一定会告诉服务端"我要操作哪个租户"(放在 header、路径或 body 里)。
这里有一个必须讲清的问题:这个值能不能信?
结论:可以读,但它的作用只是"选择上下文",验证权始终在服务端。
配套项目把这个区分写进了函数签名(identity.py):
def require_tenant(conn, claimed_tenant_id):
identity = current_identity()
if claimed_tenant_id is not None and claimed_tenant_id != identity.tenant_id:
raise TenantMismatch("claimed_tenant_mismatch")
return identity.tenant_id # ← 返回服务端认定的,不是客户端传的
返回的是 identity.tenant_id,不是 claimed_tenant_id——即使两者相等,返回的也是从数据库读出来的那个。
这样后续所有代码拿到的租户 id 都不需要再"假设它是对的"。
实测输出:
=== 03 客户端声称的租户不被采信 ===
当前身份的租户: t_acme
声称 t_globex -> TenantMismatch | claimed_tenant_mismatch
注意这个拒绝的名字是 claimed_tenant_mismatch,与 not_a_member_of_tenant 是两回事:
not_a_member_of_tenant:解析身份阶段就发现你不是这个租户的人;claimed_tenant_mismatch:你是这个租户的人,但你这次请求头里写了个别的租户。
第二种通常不是攻击,而是前端的 bug 或者用户切租户时页面没刷新。
把它们区分开,排障时能省很多时间——这是配套项目刻意把异常类型拆细的原因之一。
伪造 header 会发生什么
# 正常:u_bob 是 acme 的人,声称自己是 acme
GET /me (X-User-Id: u_bob, X-Tenant-Id: t_acme) → 200, member=m_bob_acme
# 伪造:u_bob 声称自己在 globex
GET /me (X-User-Id: u_bob, X-Tenant-Id: t_globex) → 403 not_a_member_of_tenant
伪造租户 header 无法变成别人:查 member 表时 user_id + tenant_id 是联合条件,
查不出记录就 403。伪造用户 id 在配套项目里当然也是成功的——因为配套项目为了自包含,
用的是明文 header(00 章与第八节都说明了这一点,生产环境必须用签名 token)。
五、失败注入:三个能真实制造越权的改法
改法 1:解析身份时漏掉租户条件(最经典)
# 错误写法
SELECT id, tenant_id, org_id FROM member WHERE user_id = ?
效果:请求头带任意租户 id 都能解析出身份,随后的所有判定都在错误的租户上下文里进行。
改法 2:require_tenant 返回客户端传入的值
def require_tenant(conn, claimed_tenant_id):
return claimed_tenant_id # ← 直接采信
效果:所有后续查询的 tenant_id 变成客户端可控。这是"处处加了 tenant_id 条件,但值是攻击者给的",
比没加条件更危险,因为它看起来是做了隔离的。
改法 3:业务表只存 user_id 不存 member_id
效果:跨租户数据混在一起,查询时无法用 member_id 过滤,只能靠 join user 表——
而 user 表里没有租户维度,于是要么全都能看见,要么全看不见。
三个改法的共同点:都不会报错。这是本课反复出现的模式,也是为什么权限问题只能靠"主动破坏 + 断言"来防。
六、误判澄清
| 误解 | 核对 | 结论 |
|---|---|---|
"用户表加个 tenant_id 字段就行" |
一个用户可能同时属于多个租户 | 单值字段表达不了多租户;必须拆出 member 层 |
| "客户端传的租户 id 要校验它是不是真实存在的" | 校验存在性不等于校验归属 | 要校验的是"当前身份是否属于该租户" |
"user_id 是全局的,所以可以用它做外键" |
角色与数据范围挂错了主体 | 业务表外键应该是租户内身份 member_id |
| "非成员返回 403 就够了" | 403 与"用户不存在"可区分 | 统一成同一个原因,避免账号枚举(第 05 章) |
| "虚拟团队也算租户" | 租户是隔离边界,团队是组织关系 | 租户内还要有工作空间这类次级边界(workspace) |
七、生产环境怎样替换
| 教学替身 | 生产替换 | 要点 |
|---|---|---|
明文 header X-User-Id |
签名 token(JWT/session),服务端解析出 user_id | 配套项目为了自包含才用明文,生产绝不可行 |
单租户 user 表 |
user + member 两层 |
迁移时先加 member 表并回填(04 章配套的迁移清单) |
| 每处手写 tenant 条件 | 统一注入(拦截器/RLS) | 与 02 章的结论一致:机制化,别靠人记得 |
| 拒绝原因不区分 | 统一错误码 + 内部日志区分 | 对外统一,对内可追溯 |
八、迁移清单:从单租户拆多租户
如果你手上是一个单租户系统要改多租户,按这个顺序做,每步都可回滚:
- 建
member表,用user_id回填,每人一个身份,租户为默认租户; - 业务表加
member_id列,从user_id映射回填(此时允许 NULL,便于分批); - 建
tenant表并把默认租户写进去; - 把业务查询的过滤条件从
user_id = ?改成member_id = ?(此时仍只有一个租户,行为不变); - 接入统一注入机制,把散落的 tenant 条件收口;
- 最后才开放多租户入口(切租户接口、租户注册流程)。
关键顺序:第 2 步和第 4 步之间不要跳。先有 member_id 列再改查询,
和先改查询再加列,是两种完全不同的风险。
九、练习与验收
练习 1:在你的项目里,找出所有以 user_id 为外键的业务表,判断它们在多租户下会不会串数据;改成 member_id 需要动几处。
练习 2:跑第五节的三个改法,每次都用 api_probe.py 验证"能读到什么",并写出对应的请求。
练习 3:按第八节的六步,为你的项目写一份迁移清单,标出每一步的可回滚方式与验证方法。
验收点:不看资料,说出 user_id / member_id / tenant_id 各自的职责与挂载对象;解释为什么业务表外键应该是 member_id;说明客户端租户 id 的正确用法与两个不同拒绝原因的区别。
现在能解释什么:三级 id 的分工与"权限挂 member_id"这条规则;客户端租户 header 是上下文选择器而非凭据;三个能真实制造越权的改法;单租户拆多租户六步迁移。
下一步:第 05 章讲隔离的边界——哪些地方会漏,以及 403 与 404 的取舍。