聊聊多租户 SaaS 权限框架设计:RBAC、ReBAC 与套餐

聊聊多租户 SaaS 权限框架设计:RBAC、ReBAC 与套餐

做多租户 SaaS,权限是最容易越做越乱的一块。这篇文章把"谁能干什么(RBAC)""能碰哪个具体东西(ReBAC)""租户买了什么(套餐)""是谁的数据(多租户隔离)"四件事一次讲透。不讲框架怎么配,只讲为什么这么设计整体骨架长什么样。所有名词都配生活例子,尽量用人话讲明白。

目录


一、先从一个故事说起

我工作中做的是一个面向环卫企业的 SaaS 平台(车辆、人员、清扫任务、GPS 追踪)。平台上的调度员小王,他的一天是这样的:

  • 他能看到自己车队的 12 辆车,看不到隔壁车队的车;
  • 他能派单填工单,但不能删用户不能改套餐
  • 他公司买的是专业版,所以有 GPS 实时监控,但没有 AI 决策模块
  • 他登录的是蓝天环卫,另一家客户绿水环卫的数据他根本看不见。

这四个"能/不能",看着都是权限问题,其实背后是四套互相独立的机制

故事里的"能不能" 真正在管这件事的机制 变化的触发方
只看自己车队的车 数据范围 / 关系(ReBAC) 业务关系变化
能派单、不能删用户 功能权限(RBAC) 管理员调角色
专业版有 GPS、没 AI 套餐(Entitlement) 客户续费/升级
看不到绿水环卫 租户隔离(多租户) ——(永不变)

全文最重要的一句话:把这四件事分开,是这套设计的核心直觉。它们变化的频率不同、责任方不同、配错了后果也不同。混在一起写,必然越改越乱。

比如"看不到绿水环卫"是绝不能错的安全底线,应该用最硬的机制兜底;而"专业版有没有 GPS"是商业模式,今天买明天退,天天在变,绝不能写进权限表里——否则销售每改一次套餐,程序员就得改一次代码。

带着这个直觉,我们先把名词讲透。


二、把一串名词讲明白(每个配生活例子)

权限圈黑话特别多,但核心就几个。每个我都配一个生活例子。

2.1 认证 vs 授权(AuthN / AuthZ)

  • 认证(Authentication,简称 AuthN) = 你是谁。门卫查你身份证。落到系统里就是登录、验 JWT(一张带签名的"电子身份证")。
  • 授权(Authorization,简称 AuthZ) = 你能干什么。门卫查完身份证,再翻本子看你能进哪几个门。

先认人,再放行。认证回答"是谁",授权回答"能干啥",两步不能混。

2.2 RBAC —— 工牌制

RBAC(Role-Based Access Control,基于角色的访问控制) 就是工牌制度。保安不认识你,只看你工牌颜色:

红牌(管理员)→ 哪都能进;蓝牌(调度员)→ 只进调度室;绿牌(司机)→ 只进车库。

落到系统里是一条链:用户 → 角色 → 权限

小王 ──担任──► 调度员(角色)
调度员 ──含有──► oa:task:create(权限码)

所以小王能派单。

  • 优点:简单、快,管住 80% 的"职位 → 能干啥"。
  • 缺点:特例一多就角色爆炸——"调度员但只管 A 区""临时调度员"……角色越建越多,最后没人敢动。

2.3 ReBAC —— 关系制

ReBAC(Relationship-Based Access Control,基于关系的访问控制) 是一张社交关系图。能不能进,看你和这个东西什么关系

"你是这辆车的驾驶员吗?" → 是 → 能看这辆车的轨迹。

ReBAC 把权限写成一句话,叫关系元组(Relationship Tuple)<对象, 关系, 主体>。判定过程 = 在关系图上找路。

小王 ──驾驶员──► 陕A12345           小王是这车的驾驶员
陕A12345 ──属于──► 蓝天环卫          这车归蓝天
小王 ──成员──► 蓝天环卫              小王是蓝天的人

小王想看陕A12345?图上找得到路径 → 允许。换个绿水环卫的老李,图上找不到路 → 拒绝

  • 优点:天然表达层级、归属、共享,不会角色爆炸。Google Drive、GitHub 的权限都是这套。
  • 缺点:判定要在图上找路,比 RBAC 慢、复杂。

2.4 RBAC + ReBAC:配合,不是二选一

很多人第一次接触会问:到底用哪个?答案是两个都用,各管一头

管什么 粒度 例子
RBAC "能干这个操作吗" 小王是调度员 → 能"派单"
ReBAC "能碰这个具体东西吗" 小王 → 能碰"陕A12345"这辆车

配合方式:先 RBAC(快,挡住大多数),过了就放行;没过再 ReBAC(细,补救)。两者互补,不互斥。这是后文设计思想里最重要的一条。

2.5 套餐(Entitlement)—— 你买了什么

Entitlement(权益/套餐) 和权限完全分开,它属于商业模式层。

小王公司买"专业版",含 GPS、OA,不含 AI。

套餐决定整块功能有没有,不管你什么角色:

  • 套餐不含 GPS → GPS 的页面、按钮、API 全没有(返回 402 套餐不足);
  • 套餐含 OA → 还要再看 RBAC,决定你具体能干 OA 里的啥。

一句话:套餐管"有没有这个模块",RBAC 管"模块里你能干啥"

为什么必须分开?因为套餐是销售在改,权限是管理员在改。把"买没买 GPS"写进角色表,等于让销售的每一次续费都捅一下权限系统——这是灾难。

2.6 多租户(Multi-Tenant)—— 合租公寓

SaaS(Software as a Service,软件即服务) 是把一套软件租给很多客户用。租户(Tenant) 就是其中一个客户/公司。蓝天环卫、绿水环卫是两个租户,数据互相看不见。

怎么做到?每张业务表都加一列 tenant_id(租户标识),查询时强制 WHERE tenant_id = ?。蓝天的查询只带出蓝天的数据。

像合租公寓:大家共用一套房子(一个系统、一个数据库),但你的房间(tenant_id)别人进不来。这种"共享一套、按字段隔离"的部署方式叫 Pool 模型(池化)。

2.7 PEP / PDP —— 门口保安和决策室

这两个词来自经典的 XACML 权限架构,听起来唬人,其实就是分工:

  • PEP(Policy Enforcement Point,执行点) = 门口保安:只负责拦人、放人,不判断。判断的事交给对讲机那头。
  • PDP(Policy Decision Point,决策点) = 对讲机那头的决策室:收到"小王要进调度室",查规则,回"放行"或"拒绝"。

为什么要分开? 权限规则会变、会复杂。集中在决策室一处维护,保安保持简单。否则规则散得到处都是,改一个漏一个——这就是 bug 温床。

还有两个配角认识一下就行:PIP(信息点,提供"小王有哪些角色""这车归谁"等数据)、PAP(管理点,后台增删改角色和规则的地方)。运行时判定主要靠 PEP + PDP。


名词讲完了。下面看整体骨架。


三、整体框架:一张图看懂五层模型

把上面这些名词摞起来,就是一个五层模型(自底向上,每层各司其职):

┌────────────────────────────────────────────────────┐
│ 5. 审计 Audit     所有判定留痕,可追溯、可对账        │
├────────────────────────────────────────────────────┤
│ 4. 配额 Quota     席位/车辆/调用量上限(超了 429)    │
├────────────────────────────────────────────────────┤
│ 3. 套餐 Entitlement  租户买了哪些功能(没买 402)     │  ← 商业模式层
├────────────────────────────────────────────────────┤
│ 2. 授权 Authorization  RBAC(粗)+ 租户隔离 + ReBAC(细)│  ← 核心
├────────────────────────────────────────────────────┤
│ 1. 认证 Authentication  登录、验 JWT、识别租户        │
└────────────────────────────────────────────────────┘

一个请求的旅程(小王点"派单"):

① 认证    验 JWT → 小王(userId=8, 租户=蓝天, 角色=调度员)
② 套餐    蓝天买了 OA 模块吗?→ 买了 → 过
③ 租户    这个资源的 tenant 是"蓝天"吗?→ 是 → 过
④ RBAC    调度员含 oa:task:create 吗?→ 含 → 放行 ✅
   (没含 → 再查 ⑤ ReBAC:小王和这任务有关系吗?)
⑤ 业务    创建任务(自动带 tenant_id=蓝天)+ 计费用量 +1
⑥ 审计    记一笔:allow

几种典型结局:

  • 小王没派单权限 → 第④步 RBAC 拒绝 → 403
  • 小王点别人的车 → RBAC 过了,ReBAC 查关系,没关系 → 403
  • 蓝天没买 GPS → 第②步套餐拒绝 → 402
  • 授权服务挂了(数据库异常)→ 503绝不放行(这点很关键,见第四节)。

五层的顺序也有讲究:便宜的、能挡掉大多数请求的闸放前面(套餐、租户隔离),贵的、细的放后面(ReBAC 要在图上找路)。这叫"短路求值",能省则省。


四、设计思想:六条原则

框架的"形"是五层模型,"神"是下面六条原则。理解了这六条,换任何语言、任何框架都能套。

4.1 唯一权限码:三处逐字符一致

所有权限用一个统一格式的权限码(Permission Code) 表示:

权限码 = 模块:资源:操作          (冒号分隔)
操作码:list / query / create / update / delete / handle / assign / export / import
例子: oa:vehicle:create         OA·车辆·新增
       system:user:delete        系统·用户·删除

铁律:同一个权限码,在后端注解、前端按钮、数据库三处必须一字不差。差一个字符就是 bug——按钮永远不显示,或者 API 永远 403。

这条听起来平淡,但它是真实项目里出 bug 最多的地方(见第八节"权限码漂移")。所以我们会要求应用启动时自动对账:三处不一致就启动失败,而不是等用户反馈。

4.2 PEP/PDP 分离:保安不思考

回到 2.7 的比方:保安(PEP)只做三件事——抽上下文(谁、要干啥、对哪个东西)→ 问决策室 → 把结果翻译成 HTTP 状态码。任何判定逻辑都不准写在保安里。

判定逻辑全部集中在决策室(PDP)一处。好处:规则集中、可测、可审计;保安保持极简,几乎不会出 bug。

4.3 RBAC 是主干,ReBAC 是补充:先粗后细

判定顺序永远是 RBAC 先,ReBAC 后

  • RBAC 通过 → 直接放行(粗粒度够用就够用,不浪费时间在图上找路);
  • RBAC 不过 → 再用 ReBAC 看有没有细粒度关系。

这是个重要的取舍:RBAC 解决 80% 的场景,ReBAC 补那 20% 的细粒度。不要一上来就全 ReBAC,那是给自己挖坑(性能、复杂度、调试难度全上来)。

4.4 套餐、配额、权限各管各的:解耦

四件事绝不互相塞:

回答 错配示例(别这么干)
RBAC 这个用户能干这个操作吗 ——
套餐 这个租户买了这个功能吗 买 GPS 模块 ≠ 给个"GPS 角色"
配额 这个租户用量超上限了吗 超席位 ≠ 隐藏功能(应返回 429 引导加购)
计费 这个租户该付多少钱 ——

解耦的收益:销售改套餐、运维调配额,都不用碰权限代码。各走各的表,各改各的。

4.5 租户隔离 fail-closed:宁可错杀

fail-closed(故障关闭) = 出问题时拒绝,而不是放行;对应的概念是 fail-open(故障打开) = 出问题时放行。

权限系统必须 fail-closed

  • 拿不到 tenant_id(非超管)→ 拒绝,绝不放行;
  • 授权服务/数据库挂了 → 返回 503绝不放行
  • 缓存读异常 → 503,不返回"允许"。

宁可错杀(用户看到 503,重试就好),不可错放(数据泄漏,没法挽回)。这是安全底线,没有例外。

与之配套的还有 Secure-by-default(默认安全):默认拒绝;权限码不声明 = 公开(白名单),声明了才受控。

4.6 接口稳定,实现可换:渐进演进

现阶段决策室(PDP)是手写 Java + MySQL:RBAC 直接查表,ReBAC 在关系图上做广度搜索(BFS)。零新基础设施,够用。

但当业务复杂到自写求值扛不住时,可以把 PDP 的 ReBAC 实现换成工业级引擎(Google Zanzibar 的开源版 SpiceDB / OpenFGA),对外接口不变,保安和业务零改动

这是"渐进演进"的核心:接口先定好、保持稳定,实现可以随时换。不要一上来就上重武器,也不要把自己锁死在手写实现里。


五、核心抽象:四个词说清一次判定

整个决策室对外就一个方法、四个数据结构:

AuthorizationEngine.decide(Subject, Action, Resource) → Decision
  • Subject(主体):谁。(userId, tenantId, 是否平台超管)
  • Action(动作):要干啥。(一组权限码, 匹配模式 ANY/ALL)
  • Resource(资源):对哪个东西。(类型, 对象id, 归属租户) —— 默认空,需要细粒度时才填
  • Decision(决策):结果。(允许/拒绝, 原因) —— 原因区分 403 / 402 / 429 / 503

决策室的判定逻辑(伪代码,对应五层短路):

Decision decide(s, a, r) {
  if (s.platformAdmin)                  return allow();                // ① 平台超管
  if (!entitlement.ok(s, a))            return deny(NO_ENTITLEMENT);   // ② 没买 → 402
  if (r.tenantId != s.tenantId)         return deny(NO_TENANT);        // ③ 跨租户 → 403
  if (rbac.ok(s, a))                    return allow();                // ④ RBAC 通过
  if (r.hasObject && rebac.ok(s, a, r)) return allow();               // ⑤ ReBAC 关系可达
  return deny(FORBIDDEN);                                              //   → 403
  // 任何异常 → deny(UNAVAILABLE) → 503,fail-closed
}

注意 ④⑤:路由级粗筛 + 资源级精筛。大多数接口在第④步(RBAC)就结束了;只有"同一租户内还要按车队/所有人细分"时,才由业务代码主动带着真实资源调一次第⑤步(ReBAC)。这是性能和细粒度的平衡。


六、ReBAC 怎么建模:一句话权限 + 关系图上找路

ReBAC 是这套设计里最"高级"的部分,但其实不难理解。

建模就是把权限写成一句句关系元组 <对象, 关系, 主体>

(vehicle, 陕A12345, owner,  tenant, 蓝天,    蓝天)   # 这车归蓝天
(vehicle, 陕A12345, driver, user,   小王,    蓝天)   # 小王是这车的驾驶员
(vehicle, 陕A12345, parent, dept,   清扫3组, 蓝天)   # 车属清扫3组
(dept,    清扫3组,  parent, tenant, 蓝天,    蓝天)   # 清扫3组属蓝天
(tenant,  蓝天,     member, user,   小王,    蓝天)   # 小王是蓝天成员

判定就是从"陕A12345"出发,在图上反向找路,看能不能走到"小王":

陕A12345 ──owner──► 蓝天 ◄──member── 小王     ✅ 走得通(=租户归属)
陕A12345 ──driver──► 小王                       ✅ 直接关系

任一条路走通 → 允许。这就是关系闭包求值(工程上用广度优先 BFS,并限制最大深度和访问元组数,防止关系环把服务拖死)。

一个漂亮的副作用:租户隔离其实就是 ReBAC 的一种特殊关系owner → tenant)。所以 ReBAC 建好以后,跨租户自然走不通——多租户隔离和细粒度被统一在了同一张关系图里。

演进:这套关系元组结构和 Google Zanzibar(业内 ReBAC 天花板,Google Drive / YouTube / Calendar 的权限引擎)几乎一致。哪天扛不住了,把元组原样导入 SpiceDB / OpenFGA,决策室换套实现就行(见 4.6)。


七、SaaS 的商业化骨架:套餐、配额、计费

权限管的是"能不能用",商业化还要回答"买了没""超没超""收多少钱"。这三层架在权限之上:

没过返回 用户看到
套餐闸(Entitlement) 402 "套餐不含此功能" + 升级引导
配额闸(Quota) 429 "已达上限" + 加购引导
计费(Billing) ——(无感) 正常使用,后台默默记一笔用量

重点:402 / 429 不是普通报错,是商业化信号。前端应该引导升级/加购,而不是甩一个红色错误框。

多租户的数据怎么挂 tenant_id,分三种(建表前先想清楚属于哪一类):

数据类型 例子 怎么挂租户
租户业务数据(绝大多数) 车辆、任务、工单 表内 tenant_id 列 + 查询强制过滤
外部共享池 / 海量时序 GPS 设备、定位流水 单独建绑定关系表(设备→租户),本体不加列
平台公共数据 字典、菜单、套餐目录 不挂(全平台共享)

第三方原生表(如 GPS 引擎自带的设备表)不要硬加 tenant_id——会破坏升级兼容、迁移成本极高、而且设备是可调拨的共享资产。用一张绑定关系表表达"设备归哪个租户"是更优解。

计费这里只点到为止:业务动作发生 → 记一行用量(Metering 计量)→ 月底按套餐计价(Rating 计价)→ 出账单(Invoicing 出账)→ 生成应收推财务系统 → 收款核销。金额全程用整数分,避免浮点误差。这是 Stripe、Lago 等计费 SaaS 的通用范式,我们自建是为了财务自主。


八、最简示例:六张 SQL 搭一个能跑的骨架

概念讲完了,我们用最少的表和代码,把"小王派单"这条路真正跑通。生产级实现远比这复杂(缓存、索引、并发、审计、fail-closed 兜底),但骨架就是这些——把这节看懂,整套设计就落地了。

8.1 六张表,撑起整个骨架

-- ① 租户(带套餐)
CREATE TABLE tenant (
  id        VARCHAR(32) PRIMARY KEY,        -- 'acme'
  name      VARCHAR(64) NOT NULL,
  plan_code VARCHAR(32) NOT NULL             -- 'basic' / 'pro'
);

-- ② 用户(最简:一人一租户一角色;生产用 membership 多对多)
CREATE TABLE app_user (
  id              BIGINT PRIMARY KEY,        -- 8
  name            VARCHAR(64) NOT NULL,
  tenant_id       VARCHAR(32) NOT NULL,
  role_code       VARCHAR(32) NOT NULL,      -- 'dispatcher'
  platform_admin  TINYINT NOT NULL DEFAULT 0
);

-- ③ 权限码字典(唯一源,三处对账基准)
CREATE TABLE permission (
  code VARCHAR(64) PRIMARY KEY               -- 'oa:vehicle:create'
);

-- ④ 角色 ↔ 权限
CREATE TABLE role_permission (
  role_code       VARCHAR(32) NOT NULL,
  permission_code VARCHAR(64) NOT NULL,
  PRIMARY KEY (role_code, permission_code)
);

-- ⑤ 套餐 ↔ 功能(套餐闸查这个)
CREATE TABLE plan_feature (
  plan_code    VARCHAR(32) NOT NULL,
  feature_code VARCHAR(32) NOT NULL,         -- 'oa' / 'gps'(权限码首段)
  PRIMARY KEY (plan_code, feature_code)
);

-- ⑥ ReBAC 关系元组(细粒度 + 租户隔离)
CREATE TABLE relation_tuple (
  object_ns  VARCHAR(32) NOT NULL,           -- 'vehicle'
  object_id  VARCHAR(64) NOT NULL,           -- '陕A12345'
  relation   VARCHAR(32) NOT NULL,           -- 'owner' / 'driver'
  subject_ns VARCHAR(32) NOT NULL,           -- 'tenant' / 'user'
  subject_id VARCHAR(64) NOT NULL,           -- 'acme' / '8'
  tenant_id  VARCHAR(32) NOT NULL,
  PRIMARY KEY (object_ns, object_id, relation, subject_ns, subject_id)
);

六张表对应四件事:①② 管多租户,③④ 管 RBAC,⑤ 管套餐,⑥ 管 ReBAC。注意 ⑥ relation_tuple 一身二职——它既是 ReBAC 的关系图,又通过 owner → tenant 这条边天然承载了租户隔离

8.2 灌入小王的数据

-- 蓝天环卫买的是专业版(含 oa + gps);基础版只有 oa
INSERT INTO tenant VALUES ('acme', '蓝天环卫', 'pro');
INSERT INTO plan_feature VALUES ('pro', 'oa'), ('pro', 'gps'), ('basic', 'oa');

-- 小王:蓝天的调度员
INSERT INTO app_user VALUES (8, '小王', 'acme', 'dispatcher', 0);

-- 权限码
INSERT INTO permission VALUES
  ('oa:vehicle:list'), ('oa:vehicle:create'), ('oa:vehicle:view');

-- 调度员角色:能查、能建、能看详情
INSERT INTO role_permission VALUES
  ('dispatcher', 'oa:vehicle:list'),
  ('dispatcher', 'oa:vehicle:create'),
  ('dispatcher', 'oa:vehicle:view');

-- 陕A12345 归蓝天;小王是它的驾驶员
INSERT INTO relation_tuple VALUES
  ('vehicle', '陕A12345', 'owner',  'tenant', 'acme', 'acme'),
  ('vehicle', '陕A12345', 'driver', 'user',   '8',    'acme');

8.3 三条查询,搞定三层判定

每一层都是一条 SQL,有结果 = 通过

-- (a) 套餐闸:蓝天有没有买 'oa' 功能?
SELECT 1 FROM plan_feature pf
JOIN tenant t ON t.plan_code = pf.plan_code
WHERE t.id = 'acme' AND pf.feature_code = 'oa';

-- (b) RBAC:小王有没有 'oa:vehicle:create'?
SELECT 1 FROM app_user u
JOIN role_permission rp ON rp.role_code = u.role_code
WHERE u.id = 8 AND rp.permission_code = 'oa:vehicle:create';

-- (c) ReBAC:小王和陕A12345 有没有关系?
--     直接关系(驾驶员)或租户归属(owner→tenant),任一可达即可
SELECT 1 FROM relation_tuple
WHERE object_ns='vehicle' AND object_id='陕A12345'
  AND relation='driver' AND subject_ns='user' AND subject_id='8'
UNION
SELECT 1 FROM relation_tuple
WHERE object_ns='vehicle' AND object_id='陕A12345'
  AND relation='owner' AND subject_ns='tenant'
  AND subject_id=(SELECT tenant_id FROM app_user WHERE id=8);

8.4 把它们串起来:一个 decide 函数

把三条查询按五层短路拼起来,就是完整的决策室(伪代码):

def decide(user, code, resource=None):
    # ① 平台超管
    if user.platform_admin: return "allow"

    feature = code.split(":")[0]                  # 'oa:vehicle:create' → 'oa'

    # ② 套餐闸 → 402
    if not sql("SELECT 1 FROM plan_feature pf JOIN tenant t ... "
               "WHERE t.id=? AND pf.feature_code=?", user.tenant_id, feature):
        return 402

    # ③ 租户隔离(资源级才查)→ 403
    if resource and resource.tenant_id != user.tenant_id:
        return 403

    # ④ RBAC → allow
    if sql("SELECT 1 FROM app_user u JOIN role_permission rp ... "
           "WHERE u.id=? AND rp.permission_code=?", user.id, code):
        return "allow"

    # ⑤ ReBAC(资源级才查)→ allow
    if resource and rebac_reachable(user, resource):
        return "allow"

    return 403                                     # 兜底拒绝
    # 任何 SQL 异常 → return 503(fail-closed,绝不放行)

8.5 跑两遍,看对错

场景一:小王创建车辆 decide(小王, "oa:vehicle:create")

① 超管? 否
② 蓝天(pro) 含 'oa'? → 查 plan_feature → 有 ✅
③ 资源级? 路由级请求 resource=None,跳过
④ 小王(dispatcher) 有 'oa:vehicle:create'? → 查 role_permission → 有 ✅ → allow

场景二:绿水环卫的老李(user=9, tenant=green)想看陕A12345
decide(老李, "oa:vehicle:view", resource=陕A12345)

① 超管? 否
② green 含 'oa'? 假设有 ✅
③ 陕A12345.tenant_id='acme' ≠ 老李.tenant_id='green' → 403 ❌(租户隔离挡住)

就算第③层漏了,第⑤层 ReBAC 在关系图上也找不到 green → 陕A12345 的路,照样 403。两层纵深防御,任一层都能挡住跨租户越权——这就是 fail-closed 加多闸叠加的价值。

这就是骨架的全部:六张表 + 一个 decide()。生产化要补的是审计表、缓存、索引、并发控制,以及把每个 sql() 包进 try/catch 确保异常时返回 503——但核心模型你已经有了,剩下的是工程打磨。


九、踩过的坑 & 业界是怎么做的

8.1 真实项目里反复出现的几个坑

现象 根因 对策
权限码漂移 按钮永远不显示 / API 永远 403 前端用了 115 个权限码,数据库只有 46 个 启动时三方对账,不一致就启动失败(见 4.1)
缓存 fail-open 普通用户突然能干所有事 数据库挂了,缓存回退返回了"超管通配" 缓存也 fail-closed,读异常 → 503(见 4.5)
占位页 点菜单显示"建设中" 前端菜单 key(连字符)和数据库 code(冒号)不一致 统一用权限码冒号格式当 key
跨租户泄漏 A 客户看到 B 客户数据 某个查询漏了 WHERE tenant_id=? 租户隔离下沉到数据访问层,不靠业务自觉

这四个坑的共同教训:不要靠人自觉,要靠机制兜底。对账靠启动校验、隔离靠数据层强制、缓存靠 fail-closed。自觉会遗漏,机制不会。

8.2 业界方案对照(我们取了什么、舍了什么)

业界方案 它是干嘛的 我们借鉴了什么
Google Zanzibar ReBAC 的工业天花板 关系元组 + 图可达;暂不要它的一致性协议
OpenFGA / SpiceDB Zanzibar 的开源实现 关系可组合的建模思想;作为未来可平移的目标
AWS Cedar 策略语言,默认安全 fail-closed + 默认拒绝;暂不引入策略 DSL
Auth0 / WorkOS 多租户身份与 RBAC 一个用户可属多个租户、每租户独立角色
Stripe / Lago 计费 SaaS 计量→计价→出账三段式;我们自建保财务自主
AWS SaaS Lens 多租户部署模型 Pool 池化模型 + tenant_id 逻辑隔离

一句话:思想向业界最顶级的看齐,落地用最朴素的技术(MySQL + 手写)先跑起来,把复杂度留到真正需要的时候(4.6 的渐进演进)。


十、术语速查表

一句话解释
SaaS 软件即服务,一套软件租给很多客户用
Tenant / 租户 一个客户/公司;多租户 = 多客户共用一套系统
tenant_id 租户标识列,数据隔离的依据
AuthN / 认证 你是谁(查身份证)
AuthZ / 授权 你能干什么(查能进哪些门)
JWT 带签名的"电子身份证",登录后下发
RBAC 工牌制,角色 → 权限,管"能干啥操作"(粗)
ReBAC 关系制,关系图上找路,管"能碰哪个东西"(细)
关系元组 <对象, 关系, 主体>,ReBAC 的基本单元
Entitlement / 套餐 租户买了哪些功能(商业模式层)
Quota / 配额 用量上限(席位 / 车辆 / 调用量)
PEP / PDP 执行点(门口保安)/ 决策点(决策室)
PIP / PAP 信息点(供数据)/ 管理点(后台增删改规则)
权限码 模块:资源:操作,唯一通行证,三处必须一致
fail-closed / fail-open 故障时拒绝(授权必须如此)/ 故障时放行(绝不能这样)
Secure-by-default 默认安全,默认拒绝、显式授权
Pool 模型 多租户共享一套栈,靠 tenant_id 逻辑隔离
402 / 403 / 429 / 503 套餐不足 / 禁止访问 / 超配额 / 授权不可用
Zanzibar / OpenFGA 业界 ReBAC 引擎,未来可平移的目标

写在最后

回头看,这套设计其实就一句话:

把"认证、授权(RBAC + ReBAC)、套餐、多租户"四件事分开,每件用最合适的方式管,再用一个稳定的接口把它们粘起来——朴素地跑,留好演进的后路。

  • 想清楚边界(四件事分开),比选什么框架重要;
  • 用机制兜底(对账、强制隔离、fail-closed),比靠自觉重要;
  • 接口稳定、实现可换,比一次性上重武器重要。

权限这东西,做对了用户无感,做错了处处是坑。希望这篇能帮正在做多租户 SaaS 的朋友少走点弯路。文中很多取舍并非唯一解,欢迎交流指正。

posted @ 2026-08-01 08:00  AJun816  阅读(11)  评论(0)    收藏  举报