设计一个 B2B SaaS 平台的授权系统(类似 Notion / GitHub / Figma):组织结构是 Org → Team → Project → Resource 四层,权限层层继承——Org admin 能动一切,Project member 只能碰自己项目。表面只是「这个用户能不能读这个文档」,但它是每个 API 请求都要过的最热路径,错一次就是越权泄露,慢一点就拖垮整站。
把它当真实系统来定规模:
本期讲四件事:用什么模型表达权限、层级继承怎么算得快、租户怎么隔离、审计日志怎么做到不可抵赖。
graph TD
APP["业务服务
API / 前端 BFF"]
PDP["授权决策点 PDP
Check(user, perm, obj)?"]
CACHE["权限缓存
分片+复制 · 内存过滤"]
STORE["关系存储
relation tuples · Postgres/Spanner"]
CDC["CDC / 失效流
binlog → Kafka"]
AUDIT["审计日志
append-only · 哈希链"]
APP -->|"1. check"| PDP
PDP -->|"2. 命中"| CACHE
CACHE -->|"3. miss 回源"| STORE
STORE -->|"4. 变更"| CDC
CDC -->|"5. 失效缓存"| CACHE
APP -->|"6. 权限变更/敏感访问"| AUDIT
PDP(Policy Decision Point)是核心:业务代码不自己判断权限,而是统一问 PDP「这个 subject 对这个 object 有没有这个权限」。PDP 把判定逻辑(policy)和判定数据(谁属于哪个组、谁是谁的 owner)集中起来——授权逻辑下沉到数据层而非散落在每个微服务里,这是 Airbnb Himeji 和 Google Zanzibar 共同的中心化思路。缓存层分片+复制、内存中做关系过滤,miss 才回源;写路径用 CDC 把变更转成缓存失效事件。审计日志是旁路,独立链路,不在 check 热路径上。
一句话 trade-off:RBAC 简单但角色会爆炸;ABAC 灵活但难审计、难回答「谁能访问 X」;ReBAC(Zanzibar 式)天然表达层级与共享,但要自己建图遍历引擎。
【原理】 三种模型回答的是同一个问题、用不同的数据结构:
(object#relation@subject),判定是在关系图上做可达性遍历。「doc:readme#viewer@user:alice」「doc:readme#viewer@group:eng#member」——viewer 可以是一个用户,也可以是「eng 组的所有成员」,于是层级与共享自然展开。这是 Google Zanzibar 的核心抽象。| 模型 | 强项 | 牺牲 / 陷阱 | 适合 |
|---|---|---|---|
| RBAC | 简单、好审计、人人懂 | 角色组合爆炸("role explosion");难表达"只对这个项目" | 权限维度少、组织扁平 |
| ABAC | 极灵活、能上下文(时间/IP/属性) | 难反查"谁能访问 X";策略多了难推理;易出"意外放行" | 合规/上下文敏感、动态规则 |
| ReBAC | 天然层级/继承/细粒度共享;可反查 | 要自建/引入图遍历引擎;深图遍历有延迟风险 | 多层级、协作共享(文档/代码/项目) |
【代码/伪代码】 ReBAC 的 check 本质是带"用户集重写"的递归图遍历:
# pseudo-code — Zanzibar 式 check:obj 上的 relation 是否包含 subject?
def check(obj, relation, subject) -> bool:
for tuple in tuples(obj, relation): # 读 (obj#relation@?)
u = tuple.subject
if u == subject:
return True # 直接命中
if u.is_userset(): # 形如 group:eng#member
if check(u.obj, u.relation, subject):# 递归展开 userset
return True
# userset rewrite:viewer 继承自 editor、owner,或父对象的 viewer
for rule in rewrite_rules(obj.type, relation): # 如 viewer = editor + parent.viewer
if check_rewrite(obj, rule, subject):
return True
return False
关键在 rewrite_rules:viewer = self | editor | parent→viewer 让权限沿层级向下继承——无需为每个 resource 复制权限。
一句话 trade-off:实时递归遍历写便宜读贵(深层级延迟尾巴长),物化展开读快但写放大、有失效一致性问题。
【原理】 Org→Team→Project→Doc 四层下,"Alice 能读这个 doc 吗"可能要从 doc 往上爬到 Org 再展开整个 group 树——一次 check 触发几十次元组读,深度和扇出决定尾延迟。两条优化路线:
check(group:eng, member, alice) 缓存复用;用 single-flight 合并并发回源。简单、写便宜,但冷缓存或深图时 p99 抖动。一致性陷阱:权限撤销必须及时生效,否则"刚被移出项目的人还能读 5 分钟"就是安全事故。Zanzibar 用 Zookie(一个携带时间戳的 token)保证"读权限时不早于某次写",把外部一致性和缓存新鲜度绑定——这是它区别于普通缓存系统的关键。
# pseudo-code — 撤销快速生效:版本号 + CDC 失效,而非纯 TTL
def revoke(group, member):
db.delete(tuple(group, "member", member)) # 1. 落库
bump_version(group) # 2. 该对象权限版本 +1
cdc.publish(InvalidateEvent(group)) # 3. binlog→Kafka→各缓存节点删 key
# 读侧:缓存条目带 version;check 时校验 version 未过期,否则回源
一句话 trade-off:共享库省成本但"漏一个 WHERE 就跨租户泄露";独立库最强隔离但运维/迁移成本随租户数线性爆炸。
【原理】 三档隔离强度对应三档成本:
| 方案 | 隔离强度 | 成本/运维 | 翻车点 |
|---|---|---|---|
共享库 + tenant_id 行级 | 逻辑(弱) | 最低;一套表所有租户 | 忘加 WHERE tenant_id 就跨租户泄露;吵闹邻居 |
| 共享库 + Schema/租户 | 中 | 中;连接切 schema | schema 数千个后元数据/迁移变慢 |
| 独立库/租户 | 强(物理) | 最高;N 套库要管 | 租户数线性爆炸;小租户浪费资源 |
主流 SaaS 默认共享库 + 行级(成本碾压),用 Postgres Row-Level Security (RLS) 把"漏 WHERE"从人为约束变成数据库强制约束:策略一旦建好,连接里设了 app.tenant_id,DB 自动给每条查询追加租户过滤——应用代码忘写也不会泄露。大客户(合规要求物理隔离)再单独切独立库,做混合分层。
-- Postgres RLS:把租户隔离下沉到数据库层
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- 每个连接进入租户上下文时:
SET app.tenant_id = '...'; -- 之后所有 query 自动被过滤,应用层无需手写 WHERE
RESET 租户上下文,否则下一个租户复用到带着上家 tenant_id 的连接。还要注意表 owner / BYPASSRLS 角色会绕过策略,迁移脚本别用超级用户裸跑。一句话 trade-off:审计要写在业务事务里才不丢,但又不能拖慢/污染业务库;要可信就得防篡改,但完全不可改与"可运维"是矛盾。
【原理】 审计日志和普通业务日志的根本区别:它是证据,要满足三点——完整(不丢事件)、不可篡改(事后改不了)、可追溯(who/what/when/from where)。
hash(prev_hash + payload),任何人改中间一条,后面所有 hash 对不上,篡改即暴露。再周期性把链头 hash 锚定到外部(如 WORM 存储 / 公证),连"删整段"都防住。# pseudo-code — 哈希链审计,篡改可检测
def append_audit(event):
prev = store.last() # 取上一条
record = {
"actor": event.actor, "action": event.action,
"target": event.target, "ts": now(),
"prev_hash": prev.hash,
}
record["hash"] = sha256(prev.hash + canonical_json(record))
store.append(record) # append-only,永不 UPDATE/DELETE
def verify(): # 巡检:重算每条 hash,断链即报警
for r in store.scan():
assert r["hash"] == sha256(r["prev_hash"] + canonical_json(strip_hash(r)))
if user.role==...,规模大后抽出中心化授权服务(Zanzibar/Himeji/OpenFGA),统一模型、统一审计、统一缓存。tenant_id → 跨租户读。也别忘了 BYPASSRLS 角色和迁移脚本绕过策略。普通缓存的 stale 是"读到旧值",授权里的 stale 是安全事故——刚被撤权的人读到旧权限 = 越权。但更微妙的是因果一致性:用户先把文档设为私密(写 ACL),再分享链接;接收方点开时,必须读到"已私密"这个新状态,否则把私密文档当公开读了。纯 TTL 无法保证"这次读不早于那次写"。
Zookie 是一个携带版本/时间戳的 token:写 ACL 返回 Zookie,后续 check 带上它,系统保证判定所用的快照不早于 Zookie 指定的时刻。它把"新鲜度"从模糊的 TTL 变成可证明的因果下界——这是把缓存一致性和外部一致性绑定。代价是要维护全局版本(Zanzibar 靠 Spanner 的 TrueTime),普通系统难复刻,所以多数实现退而用"版本号+CDC 主动失效"近似。
千万别展开成 200 万条 post#viewer@user:X 元组——写一次帖要插 200 万行,改一次可见性要删 200 万行,这是经典 fanout 写放大(见 Day 14)。
正确做法是用 userset 间接引用:存一条 post#viewer@user:celeb#follower,即"viewer = celeb 的 follower 集合"。check 时再展开 follower 关系。但这把成本转移到了读:每次 check 要遍历 follower 集合判断是否包含某人——对 200 万成员的集合做包含判断又太慢。
这正是 Zanzibar Leopard 索引的用武之地:对深/广的 group 预计算 flatten 后的成员位图,把"X 是否属于 celeb 的 follower"从图遍历降为索引查。trade-off 回到技术点②——写时多维护一份物化索引,换读时 O(1)。这题本质是 fanout-on-write vs fanout-on-read 在授权语境的重演。
逐个 check(先取所有项目再过滤)= N+1 授权 + 取了大量无权数据再丢弃,分页还会错乱(过滤后不足 20 个)。这是面试里区分新手和老手的题。
两条正解:① 反向索引/物化——维护 user → accessible_objects 列表,查询直接 WHERE id IN (该用户可访问集) LIMIT 20,授权下推到数据查询,一次搞定且分页正确。② 授权感知的查询——把权限条件编译进 SQL(如 RLS 或 join 权限表),让数据库在扫描时就过滤。
Zanzibar 提供 Expand/反查 API 正是为此。关键洞察:"check 单个对象"和"列出可访问对象"是两个不同问题,后者不能用前者循环解决——前者是点查,后者要反向索引。很多授权系统的性能事故出在用 check 硬循环做列表。
这是典型的分层多租户(tiered tenancy)问题。不要因为一个客户把所有人迁到独立库——成本和运维爆炸。做法:保持共享库做默认池(绝大多数中小租户),同时支持把指定租户路由到独立库/独立 schema。
关键是路由层抽象:在数据访问层加一个 tenant → shard/datasource 的映射,应用代码按 tenant_id 解析到对应连接。共享池租户走 RLS 隔离,专属租户走物理库。新客户上线只是改路由配置,不动业务代码。
难点在跨层迁移:把一个租户从共享库搬到独立库要做在线数据迁移(双写→回填→校验→切读→切写,见 Day 22 的 expand-contract)。还要统一 schema 演进——独立库不能落后于共享库的迁移版本。这题考的是"为最贵的客户做特例,但别让特例污染主路径"。
这是合规里两条要求的直接冲突:审计要不可篡改保留,GDPR 要可删除个人数据。直接删审计记录会断哈希链、毁掉不可抵赖性。
常见解法:① 加密删除(crypto-shredding)——审计记录里的个人数据不明文存,而是用每用户一把密钥加密;要"删除"该用户时销毁密钥,密文永久不可解,但记录本身(及其 hash)仍在,链不断。② 分离 PII——审计链只存 actor_id / action / target_id 这类标识符和事件结构,真实 PII 放可删的旁表,链上存的是引用;删 PII 不动链。③ tombstone——不物理删,追加一条"已删除"事件,把可见性而非数据本身改掉。
本质洞察:"不可篡改"约束的是审计事件的发生事实,"可删除"约束的是其中的个人数据载荷——把这两层解耦,就能同时满足。这也是为什么审计设计要从一开始就区分"事件骨架"和"PII 载荷"。