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"做成代码评审的强制检查项。

关联