49.4 多租户权限模型
一句话
多租户权限 = 在"谁-能做什么"上再加一维"在哪个租户内";租户 ID 必须进入每一次鉴权判定、每一个缓存键、每一条异步消息——只写进 SQL 的
WHERE是不够的,漏一处就是跨租户数据泄漏。
分工:隔离归 65 章,权限归这里
"独立库 / 共享库独立 schema / 共享表 + tenant_id"这三种隔离形态见 65.1 租户隔离模型(待补)。讨论前提是已经选了某种隔离形态,剩下要解决的是:权限怎么表达、判定放在哪、哪里最容易漏。角色、权限码与矩阵本身的建模见 49.3 权限中心与权限矩阵。
三种隔离形态对权限判定的影响
| 隔离形态 | 权限判定放哪 | 漏判的爆炸半径 | 常见误判 |
|---|---|---|---|
| 独立库(一租户一库) | 连接路由 + 租户内 RBAC | 单租户 | "库都隔离了,权限随便做"——租户内部的水平越权一样存在 |
| 共享库、独立 schema | schema 路由 + 租户内 RBAC | 单租户 | schema 切换靠 search_path,一条没切的连接就串号 |
共享表 + tenant_id | 每条 SQL 强制带租户 + 租户内 RBAC | 全租户 | 认为 ORM 会自动加条件,实际上只有显式写的才生效 |
🔑 隔离形态只决定"漏了之后炸多大",不决定"要不要判"。三种形态里,租户内部的水平越权(同租户 A 用户读 B 用户数据)都必须单独处理。
角色是分层的,别压成一层
| 层级 | 例子 | 谁授予 | 能否自建 | 判定范围 |
|---|---|---|---|---|
| 平台角色 | 平台运维、客服、审计员 | 平台侧 | 否 | 跨租户(必须留审计、多数只读) |
| 租户角色 | 租户管理员、财务、成员 | 租户管理员 | 部分可自建 | 本租户内 |
| 空间角色 | 项目管理员、只读成员 | 租户管理员 / 项目 owner | 是 | 项目内 |
| 资源级共享 | 单份文档的 viewer / editor | 资源 owner | 是 | 单资源,带有效期 |
关键约束:租户管理员可以自建角色,但只能授予"租户权限集的子集"。允许租户管理员勾选平台权限,等于给了他们自封超管的入口——这是多租户系统里最常见、也最容易被忽略的垂直越权。
| 错误做法 | 后果 |
|---|---|
| 角色表不区分平台 / 租户 | 租户管理员能把自己加成"平台管理员" |
| 权限点表不划分"可委托"标记 | 自建角色时把所有权限都列出来给人勾 |
| 平台角色不进审计 | 平台运维看谁的数据都查不到记录,合规直接不过 |
两种越权,多租户下变成三种
| 类型 | 手法 | 例子 | 防线 |
|---|---|---|---|
| 垂直越权 | 普通用户直接调管理员接口 | POST /admin/users/disable | 接口级权限码校验 |
| 水平越权(BOLA / IDOR) | 改资源 ID 读别人的数据 | /orders/o-3 换成别人的单 | 资源归属校验 |
| 跨租户越权 | 改租户参数 / 换 Host / 复用缓存 | /tenants/t-002/orders/o-3 | 租户一致性校验 + 缓存键带租户 |
第三种是前两种在多租户下的叠加,也是最容易被漏的——因为代码看起来"有鉴权":它确实校验了"这个用户有读订单的权限",只是没校验"这个订单属于这个租户"。
下面这段 SQL 在 SQLite 上实测,复现同一份代码的两种写法:
CREATE TABLE orders (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
owner TEXT NOT NULL,
amount INTEGER NOT NULL,
PRIMARY KEY (tenant_id, id) -- 主键必须带租户,否则不同租户的 id 会撞
);
INSERT INTO orders VALUES
('o-1','t-001','u-bob',100),
('o-2','t-001','u-alice',200),
('o-3','t-002','u-bob',999); -- 另一个租户,同样的 id 形态
-- ① 只按资源 id 查(忘了租户)
SELECT id, tenant_id, owner, amount FROM orders WHERE id = 'o-3';
-- ② 按 (租户, id) 查,同一请求被正确拒绝(0 行)
SELECT id, tenant_id, owner, amount FROM orders WHERE tenant_id = 't-001' AND id = 'o-3';
| ① 只按资源 id 查 | id | tenant_id | owner | amount |
| | o-3 | t-002 | u-bob | 999 |
| ② 按 (租户, id) 查 | (0 行) |
| ③ 列表接口同一错误的两副面孔 |
| 漏租户 | 2 |
| 带租户 | 1 |
第 ③ 行最值得看:列表接口的"漏租户"与"带租户"在正常情况下只差 1 行,在监控上几乎看不出差别;出问题时差的是整张表。这类 bug 靠压测和人工测试都发现不了,只能靠结构约束。
约束一:租户只从令牌来,请求参数只用于比对
package main
import (
"context"
"errors"
"fmt"
)
type ctxKey struct{}
func withToken(ctx context.Context, uid, tenant string) context.Context {
return context.WithValue(ctx, ctxKey{}, [2]string{uid, tenant})
}
var ErrNoTenant = errors.New("缺少租户上下文,拒绝执行(fail closed)")
// requireTenant 是所有数据访问的必经关口:拿不到租户就不许查
func requireTenant(ctx context.Context) (uid, tenant string, err error) {
v, ok := ctx.Value(ctxKey{}).([2]string)
if !ok || v[1] == "" {
return "", "", ErrNoTenant
}
return v[0], v[1], nil
}
type req struct {
uid string // 来自 token
tokenTenant string // 来自 token
pathTenant string // 来自 URL /api/tenants/{tid}/orders/{oid}
orderID string
}
type order struct{ ID, Tenant, Owner string }
var db = map[string]order{
"o-1": {"o-1", "t-001", "u-bob"},
"o-3": {"o-3", "t-002", "u-bob"},
}
// GetOrder 把租户校验写进唯一的数据入口,而不是散在每个 handler 里
func GetOrder(ctx context.Context, r req) (order, error) {
_, tenant, err := requireTenant(ctx)
if err != nil {
return order{}, err
}
if r.pathTenant != tenant { // 路径租户与令牌租户必须一致
return order{}, fmt.Errorf("403 跨租户访问:token=%s path=%s", tenant, r.pathTenant)
}
o, ok := db[r.orderID]
if !ok || o.Tenant != tenant { // 第二道闸:资源自身的租户
return order{}, fmt.Errorf("404 资源不存在")
}
return o, nil
}
func main() {
bob := withToken(context.Background(), "u-bob", "t-001")
cases := []struct {
name string
ctx context.Context
req req
}{
{"正常:本租户资源", bob, req{"u-bob", "t-001", "t-001", "o-1"}},
{"越权:改路径租户去够别租户订单", bob, req{"u-bob", "t-001", "t-002", "o-3"}},
{"越权:路径租户不改,直接换资源 ID", bob, req{"u-bob", "t-001", "t-001", "o-3"}},
{"漏传租户上下文(忘了中间件)", context.Background(), req{"u-bob", "", "t-001", "o-1"}},
}
fmt.Println("| 场景 | 结果 |")
fmt.Println("|---|---|")
for _, c := range cases {
_, err := GetOrder(c.ctx, c.req)
res := "✅ 放行"
if err != nil {
res = "⛔ " + err.Error()
}
fmt.Printf("| %s | %s |\n", c.name, res)
}
}
输出(本机实测):
| 场景 | 结果 |
|---|---|
| 正常:本租户资源 | ✅ 放行 |
| 越权:改路径租户去够别租户订单 | ⛔ 403 跨租户访问:token=t-001 path=t-002 |
| 越权:路径租户不改,直接换资源 ID | ⛔ 404 资源不存在 |
| 漏传租户上下文(忘了中间件) | ⛔ 缺少租户上下文,拒绝执行(fail closed) |
三条设计要点:
pathTenant != tenant直接 403,而不是"以路径为准"。以路径为准的实现里,前端传什么就查什么租户——鉴权形同虚设。- 资源查不到时返回 404 而不是 403。403 会告诉攻击者"这个 ID 存在但属于别人",等于提供了一个枚举接口。
- 缺租户上下文时 fail closed。少一个中间件就全站放行,是多租户系统最脆弱的单点。
约束二:数据层兜底,别指望每个开发者都记得写条件
代码层约束只能挡住"新写的代码",挡不住历史代码和第三方库。共享表模式下必须再加一道数据层闸门。
PostgreSQL 的行级安全(RLS)是标准答案(未验证:本机没有 PostgreSQL 实例):
-- 可抄:表级策略,按会话变量过滤
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- 关键:表 owner 默认绕过 RLS,必须 FORCE
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id', true));
-- 每个事务里设一次(SET LOCAL 只在本事务生效,事务结束自动失效)
BEGIN;
SET LOCAL app.tenant_id = 't-001';
SELECT count(*) FROM orders; -- 只看得到 t-001 的行
COMMIT;
三个必须知道的细节:
| 细节 | 说明 | 漏了的后果 |
|---|---|---|
FORCE ROW LEVEL SECURITY | 表 owner 与 BYPASSRLS 角色默认不受策略约束 | 应用连的是 owner 账号 → 策略完全不生效,且看起来是开着的 |
SET LOCAL 而不是 SET | SET 会留在连接上,被连接池复用给下一个请求 | 上个请求的租户泄漏到下个请求,串号且极难复现 |
| 变量没设时返回 NULL | current_setting(..., true) 未设返回 NULL,比较结果为 NULL → 0 行 | 这是好事:漏设变量是 fail closed,而不是全表可见 |
⚠️ 应用连接绝不能是表 owner,也不能有
BYPASSRLS。这条规则要写进建库脚本,而不是写进文档——写在文档里的规则,三个月后就会被"为了跑通迁移"临时破例。
约束三:缓存键、异步消息、搜索索引,都要带租户
| 位置 | 正确做法 | 漏了的后果 |
|---|---|---|
| 缓存键 | t:{tenant}:order:{id} | 命中别人的数据,且缓存命中率高时错误率也高 |
| 异步消息 | 消息体带 tenant_id,消费端按租户串行 | 批处理里混租户,写回时用错库 / 用错 schema |
| 搜索索引 | 索引带 tenant_id 字段,查询强制过滤 | 搜索结果里出现别家公司的数据(最典型的合规事故) |
| 对象存储路径 | s3://bucket/{tenant}/{...} + 预签名 URL 绑定租户 | 换个 key 就能下载别人的文件 |
| 限流 / 配额键 | tenant 维度独立计数 | 一个租户打满,全租户一起 429 |
🔑 缓存是最容易漏的一处,因为它的正确性在单租户测试里完全体现不出来:测试环境只有一个租户,键带不带租户前缀,命中结果都一样。只有上了生产、两个租户撞到同一个键,才暴露成"用户看到了别的公司的订单"。
租户切换:一个用户在多个租户
| 做法 | 令牌内容 | 判定依据 | 风险 |
|---|---|---|---|
| 单租户令牌(推荐) | 只含当前租户 | 令牌里的 tenant | 切换要换令牌,实现稍繁,但判定只有一处 |
| 多租户令牌 + 请求头选租户 | tenants: [t-001, t-002] | 请求头 X-Tenant | 判定依赖客户端;漏校验就是越权 |
| 每租户独立令牌前缀 | 按租户发不同令牌 | 令牌本身 | 兼容性最好,令牌数量翻倍 |
🔑 切换租户必须换令牌,不能让"当前租户"变成一个可被请求头改写的变量。判定所依赖的一切,都要在签发时固定下来(签发与撤销见 49.5 登录态与令牌生命周期)——这与"权限不放进令牌"是两条互补的规则:身份与租户放进令牌(签发时固定),细粒度权限不放令牌(改动频繁,要实时生效)。
同一账号在多台设备、多个租户下登录时的异常判定(新设备、异地、不可能旅行)见 49.6 风险登录与设备指纹。
跨租户共享:必须有独立的一层
访客、共享链接、跨租户协作,不能用"给对方的租户加个角色"来实现——那等于把对方整个租户拉进来。正确做法是独立的授权表:
CREATE TABLE resource_grant (
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
grantee_tenant TEXT NOT NULL, -- 被授权的租户
grantee_uid TEXT, -- 可空:只授权给某个人
scope TEXT NOT NULL, -- viewer / editor
expires_at INTEGER NOT NULL,-- 必须有过期时间
revoked_at INTEGER
);
判定顺序:先判本租户内权限 → 不通过再查 resource_grant(且 expires_at > now()、revoked_at IS NULL)。共享永远带过期时间,因为跨租户的授权一旦长期有效,就会变成没人敢删的历史包袱。
反模式
| 反模式 | 后果 |
|---|---|
信任前端传来的 tenant_id | 改一个参数即可跨租户读写,等价于没有租户 |
| 只在列表接口加租户过滤,详情接口忘了 | 列表看不到、详情能打开,越权更隐蔽 |
| 缓存键不带租户 | 高命中率场景下整片数据串号,且只在多租户并发时复现 |
| 平台角色与租户角色同表、同权限集 | 租户管理员自封平台管理员 |
用 403 区分"资源不存在"和"资源属于别人" | 提供资源枚举接口 |
应用账号是表 owner / 有 BYPASSRLS | RLS 写着但完全不生效,比不写更危险 |
| 共享授权没有过期时间 | 三年后没人知道谁还能访问,回收时只能全删 |
| 跨租户批处理任务不按租户分组 | 写回时串库,且事务无法回滚到"只影响一个租户" |
自检
Q1 已经选了"一租户一库",还需要做租户级鉴权吗?
▸ 答案:需要,但需要的是租户内的水平越权防护。库隔离解决的是"A 公司看不到 B 公司的数据",解决不了"A 公司的员工甲看到员工乙的订单"——后者是资源归属校验,和单租户系统里的 BOLA 完全一样。另外两处仍然要带租户:缓存键(缓存通常是跨租户共享的)和异步任务路由(消息里必须能确定写回哪个库)。
Q2 为什么"改路径里的租户"比"改资源 ID"更危险?
▸ 答案:因为它绕过的是看起来最理所当然的那道校验。改资源 ID 至少要撞上"资源归属"检查;改租户参数时,代码里往往只校验了"用户有这个租户的成员关系"(而攻击者确实是成员)或"用户有这个权限码",却没人把路径租户与令牌租户做比较。而且这类接口通常长成 /api/tenants/{tid}/...,看起来"路由已经带了租户",反而让人以为隔离是框架保证的。
Q3 RLS 在什么情况下"开了但没生效"?怎么验证它真的生效?
▸ 答案:三种情况——① 连接用的是表 owner 且没有 FORCE ROW LEVEL SECURITY(owner 默认绕过策略);② 角色带 BYPASSRLS 属性;③ 迁移脚本用超级用户执行,之后应用又用超级用户连接。验证方法不是看 ALTER TABLE 有没有报错,而是用应用实际使用的账号执行一次越权查询:设 SET LOCAL app.tenant_id='t-001' 后 SELECT count(*) FROM orders,行数必须等于该租户的行数;再用别的租户 ID 设一次,行数必须为 0。这两步都过才算生效。
Q4 一个用户属于 3 个租户,你会把 3 个租户都放进令牌吗?为什么?
▸ 答案:不会。令牌里只放当前活跃租户,切换租户重新签发令牌。理由有三:① 判定只依赖一处(令牌),不依赖请求头这类客户端可控输入;② 权限与租户在签发时固定,撤销"某租户的成员资格"只需让令牌失效,不需要在每个接口判断"这个租户还在不在列表里";③ 令牌体积可控,多租户列表会让令牌随租户数线性膨胀,而令牌每个请求都带。
Q5 多租户系统里,"缓存命中率高"为什么可能是坏消息?
▸ 答案:因为缓存键漏了租户时,命中率越高意味着错误响应被复用得越广。单租户测试永远发现不了这个问题——只有一个租户时,键带不带前缀结果相同。排查线索是:某类"数据不对"的报错集中在高 QPS 接口、且用户投诉内容是"看到了别人的数据",但数据库里的数据完全正确。修复不是加校验,而是加键前缀,并把"缓存键必须含 tenant"做成代码评审的强制检查项。
关联
- 前置:49.3 权限中心与权限矩阵
- 并列:49.5 登录态与令牌生命周期
- 下游:49.6 风险登录与设备指纹
- 待补:65.1 租户隔离模型(未写,无链接)