KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 多租户身份体系:user / member / tenant 三级 id 的分工 — keel 龙骨

单租户系统里,"用户"就是一个人。多租户系统里不是:同一个人在不同租户里是不同的身份,角色、数据范围、可见资源全都不同。这一章讲清三级 id 各自的职责,以及"客户端传来的租户 id 为什么只能当上下文选择器"这条最容易被误解的规则。

单租户系统里,"用户"就是一个人。多租户系统里不是:同一个人在不同租户里是不同的身份,角色、数据范围、可见资源全都不同。这一章讲清三级 id 各自的职责,以及"客户端传来的租户 id 为什么只能当上下文选择器"这条最容易被误解的规则。


一、现场:一个人在一个平台是管理员,在另一个平台是普通用户

一个既是"企业版客户"的员工又是"个人版用户"的技术人员,用同一个账号登录两个租户:

系统出问题的表现是:在租户 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 出租户。问题有三个:

  1. 角色挂不上:user_role 里放 user_id,同一个人在 A 租户的管理员角色会泄漏到 B 租户;
  2. 租户内属性无处安放:org_id(部门)、入职状态、租户内昵称都跟着租户走,挂到全局用户上就乱了;
  3. 切换租户要改数据:从"改 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

三行读出来是三个结论:

  1. 同一个 u_alice,两个租户拿到两个不同的 member_id——这就是多租户身份体系的全部意义;
  2. 角色跟着身份走,不是跟着人走:tenant_admin 只在 acme 有效;
  3. 不是该租户成员 → 直接拒绝,且拒绝原因统一为 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 是两回事:

第二种通常不是攻击,而是前端的 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 章的结论一致:机制化,别靠人记得
拒绝原因不区分 统一错误码 + 内部日志区分 对外统一,对内可追溯

八、迁移清单:从单租户拆多租户

如果你手上是一个单租户系统要改多租户,按这个顺序做,每步都可回滚:

  1. 建 member 表,用 user_id 回填,每人一个身份,租户为默认租户;
  2. 业务表加 member_id 列,从 user_id 映射回填(此时允许 NULL,便于分批);
  3. 建 tenant 表并把默认租户写进去;
  4. 把业务查询的过滤条件从 user_id = ? 改成 member_id = ?(此时仍只有一个租户,行为不变);
  5. 接入统一注入机制,把散落的 tenant 条件收口;
  6. 最后才开放多租户入口(切租户接口、租户注册流程)。

关键顺序:第 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 的取舍。

进入 keel 阅读