Day 30 Hard Authorization RBAC/ABAC/ReBAC Multi-tenancy Audit Log

权限与账号系统 — 一次 check 背后的图遍历、隔离与不可抵赖Authorization & Account Systems: RBAC vs ABAC vs ReBAC, Multi-tenancy, Audit Log

问题场景 + 需求约束

设计一个 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 热路径上。

关键技术点

① 授权模型:RBAC vs ABAC vs ReBAC — 用「角色」「属性」还是「关系」表达权限

一句话 trade-off:RBAC 简单但角色会爆炸;ABAC 灵活但难审计、难回答「谁能访问 X」;ReBAC(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_rulesviewer = self | editor | parent→viewer 让权限沿层级向下继承——无需为每个 resource 复制权限。

现实案例:Google Zanzibar 用关系元组统一了 Drive / Calendar / Cloud / Photos / YouTube 的授权,存储万亿级 ACL、支撑每秒数百万次检查,3 年保持 p95 < 10ms、可用性 > 99.999%(Zanzibar 论文, USENIX ATC 2019)。OpenFGA(Auth0/Okta 开源、已捐给 CNCF)把这套 ReBAC + 部分 ABAC 能力做成可直接用的引擎(openfga.dev)。

② 层级继承与 check 性能 — 深图遍历 vs 物化反向索引

一句话 trade-off:实时递归遍历写便宜读贵(深层级延迟尾巴长),物化展开读快但写放大、有失效一致性问题。

【原理】 Org→Team→Project→Doc 四层下,"Alice 能读这个 doc 吗"可能要从 doc 往上爬到 Org 再展开整个 group 树——一次 check 触发几十次元组读,深度和扇出决定尾延迟。两条优化路线:

一致性陷阱:权限撤销必须及时生效,否则"刚被移出项目的人还能读 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 未过期,否则回源
现实案例:Airbnb Himeji 基于 Zanzibar 思路自建:分片+复制的缓存层做内存过滤,miss 回 Aurora;用 SpinalTap 监听数据变更经 Kafka 失效缓存,存储数百亿关系、近百万 entity/秒、p99 约 12ms(Airbnb Tech Blog, 2021)。Notion 则用 block 树的 parent 向上指针计算权限继承——每个 block 只认一个 parent 来源,避免多父继承的歧义(The data model behind Notion's flexibility)。

③ 多租户隔离 — 共享库行级 vs Schema 隔离 vs 独立库

一句话 trade-off:共享库省成本但"漏一个 WHERE 就跨租户泄露";独立库最强隔离但运维/迁移成本随租户数线性爆炸。

【原理】 三档隔离强度对应三档成本:

方案隔离强度成本/运维翻车点
共享库 + tenant_id 行级逻辑(弱)最低;一套表所有租户忘加 WHERE tenant_id 就跨租户泄露;吵闹邻居
共享库 + Schema/租户中;连接切 schemaschema 数千个后元数据/迁移变慢
独立库/租户强(物理)最高;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
陷阱:RLS 要小心 连接池复用——连接归还池子前必须 RESET 租户上下文,否则下一个租户复用到带着上家 tenant_id 的连接。还要注意表 owner / BYPASSRLS 角色会绕过策略,迁移脚本别用超级用户裸跑。
现实案例:Salesforce 是经典共享库多租户(org_id 贯穿数据层);Slack、Notion 等也以逻辑隔离为主、对企业大客户提供更强隔离。Postgres RLS 是 Supabase 等平台多租户隔离的默认机制(PostgreSQL 官方文档 · Row Security Policies)。

④ 审计日志 — append-only 与不可抵赖(tamper-evidence)

一句话 trade-off:审计要写在业务事务里才不丢,但又不能拖慢/污染业务库;要可信就得防篡改,但完全不可改与"可运维"是矛盾。

【原理】 审计日志和普通业务日志的根本区别:它是证据,要满足三点——完整(不丢事件)、不可篡改(事后改不了)、可追溯(who/what/when/from where)

# 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)))
现实案例:AWS CloudTrail 提供日志文件完整性校验(log file integrity validation,对每批日志做哈希并签名);GitHub / Stripe 等都向企业客户暴露 audit log API 供合规导出。SOC2 / GDPR 审计要求"权限变更与数据访问可追溯",哈希链 + WORM 是常见落地方式。

扩展与优化

常见陷阱 + 面试追问

深入资源

深入思考

1. Zanzibar 的 Zookie 到底解决了缓存系统里哪个普通 TTL 解决不了的问题?为什么授权特别需要它?

普通缓存的 stale 是"读到旧值",授权里的 stale 是安全事故——刚被撤权的人读到旧权限 = 越权。但更微妙的是因果一致性:用户先把文档设为私密(写 ACL),再分享链接;接收方点开时,必须读到"已私密"这个新状态,否则把私密文档当公开读了。纯 TTL 无法保证"这次读不早于那次写"。

Zookie 是一个携带版本/时间戳的 token:写 ACL 返回 Zookie,后续 check 带上它,系统保证判定所用的快照不早于 Zookie 指定的时刻。它把"新鲜度"从模糊的 TTL 变成可证明的因果下界——这是把缓存一致性和外部一致性绑定。代价是要维护全局版本(Zanzibar 靠 Spanner 的 TrueTime),普通系统难复刻,所以多数实现退而用"版本号+CDC 主动失效"近似。

2. 一个名人在你平台有 200 万 follower,"follower 都能看我的公开帖"用 ReBAC 元组怎么存?直接展开会怎样?

千万别展开成 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 在授权语境的重演。

3. 列表页要展示用户"有权访问的 1000 个项目中的前 20 个并分页"。逐个 check 会怎样?正确架构是什么?

逐个 check(先取所有项目再过滤)= N+1 授权 + 取了大量无权数据再丢弃,分页还会错乱(过滤后不足 20 个)。这是面试里区分新手和老手的题。

两条正解:① 反向索引/物化——维护 user → accessible_objects 列表,查询直接 WHERE id IN (该用户可访问集) LIMIT 20,授权下推到数据查询,一次搞定且分页正确。② 授权感知的查询——把权限条件编译进 SQL(如 RLS 或 join 权限表),让数据库在扫描时就过滤。

Zanzibar 提供 Expand/反查 API 正是为此。关键洞察:"check 单个对象"和"列出可访问对象"是两个不同问题,后者不能用前者循环解决——前者是点查,后者要反向索引。很多授权系统的性能事故出在用 check 硬循环做列表。

4. 你用共享库 + RLS 做多租户,跑得好好的。一个金融大客户要求"我的数据绝不能和别人在同一个数据库"。怎么演进而不重写?

这是典型的分层多租户(tiered tenancy)问题。不要因为一个客户把所有人迁到独立库——成本和运维爆炸。做法:保持共享库做默认池(绝大多数中小租户),同时支持把指定租户路由到独立库/独立 schema。

关键是路由层抽象:在数据访问层加一个 tenant → shard/datasource 的映射,应用代码按 tenant_id 解析到对应连接。共享池租户走 RLS 隔离,专属租户走物理库。新客户上线只是改路由配置,不动业务代码。

难点在跨层迁移:把一个租户从共享库搬到独立库要做在线数据迁移(双写→回填→校验→切读→切写,见 Day 22 的 expand-contract)。还要统一 schema 演进——独立库不能落后于共享库的迁移版本。这题考的是"为最贵的客户做特例,但别让特例污染主路径"。

5. 审计日志用哈希链防篡改。但运维需要删除某用户数据(GDPR 删除权)——删了链就断了。这个矛盾怎么解?

这是合规里两条要求的直接冲突:审计要不可篡改保留,GDPR 要可删除个人数据。直接删审计记录会断哈希链、毁掉不可抵赖性。

常见解法:① 加密删除(crypto-shredding)——审计记录里的个人数据不明文存,而是用每用户一把密钥加密;要"删除"该用户时销毁密钥,密文永久不可解,但记录本身(及其 hash)仍在,链不断。② 分离 PII——审计链只存 actor_id / action / target_id 这类标识符和事件结构,真实 PII 放可删的旁表,链上存的是引用;删 PII 不动链。③ tombstone——不物理删,追加一条"已删除"事件,把可见性而非数据本身改掉。

本质洞察:"不可篡改"约束的是审计事件的发生事实,"可删除"约束的是其中的个人数据载荷——把这两层解耦,就能同时满足。这也是为什么审计设计要从一开始就区分"事件骨架"和"PII 载荷"。