Skip to content

Iceberg Read Restrictions:策略跟着表走了

封面

七月那篇《Agent 打破了 20 年数据治理:策略该放在库里,还是库外?》结尾押了一个判断:最终答案大概率是"治理与执行分离"的混合架构。

九月三日,这个架构有了第一份规范。Apache Iceberg 的 REST Catalog 规范合入了一个叫 Read Restrictions 的扩展,把行级过滤和列级掩码搬进了目录。

要理解它解决什么,得先看清一件长期别扭的事。Iceberg 让表数据可互操作——一个引擎写,任何引擎读;REST Catalog 让元数据可互操作——一个目录,任何客户端。但策略从来没有可互操作过

而现实里的权限几乎从不是"能看"或"不能看"这么干脆。Alice 可以看到完整的身份证号,Bob 只能看到后四位;Candice 能查美国用户,Devin 只能查西班牙用户。表级权限描述不了这种事。于是同一份策略要在每个引擎里重写一遍,或者交给一个跟"真正拥有这些表的目录"脱钩的第三方系统去管。两条路都通向同一个结果:想知道谁能看到什么,本身变成了一道系统设计题。

一、规范改了什么

变化集中在一个接口上:loadTable 的响应里多了一个可选对象 ReadRestrictions,里面装两样东西。

一是 required-row-filter,一个 Iceberg 谓词,比如 country = 'USA'。支持 =!=><INNOT IN 这些原语;EXISTS / NOT EXISTS 这类存在性检查,只要被检查的表规模合理,也能用原语表达出来——但复杂 JOIN 仍然表达不了。规范对结果的要求很硬:任何求值为假的行不得出现在结果里,从这些行派生出来的东西也不得泄漏进结果。

二是 required-column-projections,一组列加上各自要施加的掩码动作。掩码只有九种,而且每一种都被精确定义到字节:

掩码效果
mask-alphanum数字变 n,其他字符变 x,保留 ( ) , . - @
mask-to-fixed-value替换成一个固定值
replace-with-null变 NULL
show-first-4只保留前四个字符
show-last-4只保留后四个字符
truncate-to-year日期/时间戳截断到年
truncate-to-month日期/时间戳截断到月
sha-256-globalSHA-256,全局确定:同样的输入永远得到同样的输出
sha-256-query-localSHA-256,每次查询换一个随机盐

所以 4111-1111-1111-4444 经过 show-last-4 出来是 nnnn-nnnn-nnnn-4444,而 a.analyst@example.com 经过 mask-alphanum 出来是 x.xxxxxxx@xxxxxxx.xxx

最后两种值得单独说一句,因为它们是两种不同的隐私取舍。sha-256-global 全局确定,好处是脱敏之后的列仍然能 join——你没法还原用户 ID,但能把两张表里同一个用户对上。sha-256-query-local 每次查询换盐,查询内部保持一致,跨查询则完全不可关联,代价就是彻底放弃跨查询的关联分析能力。

还有一点容易被忽略但很关键:这些指令是按调用者算出来的。 同一张表,不同的人 loadTable 会拿到不同的限制;同一个人在策略或角色变更之后再拿一次,结果也可能不一样。规范因此明确写了,ReadRestrictions 只对认证头标识的那个主体有效,不得被当成全局策略缓存复用

目录侧的策略本身可以复杂得多——可以依赖查询者的属性动态变化,可以引用只有目录才理解的概念比如成本中心。下发给引擎的,只是这些复杂策略针对当前这个人求值之后的结果。如果一条策略压根没法用这套原语表达出来,目录还有一个永远可用的兜底:直接拒绝访问。

二、为什么掩码必须钉到字节

传统做法里,策略和执行在同一个地方——策略写在库里,库自己执行。这个模型的好处是不用讨论信任问题:数据库既是裁判又是执法者。

Read Restrictions 把这两件事拆开了。目录负责算出"针对这个人的限制是什么",引擎负责把它执行掉。

拆开的收益相当实在。不需要为治理再生成一份脱敏后的数据副本,也不用把查询绕进视图层,谓词下推照常工作。换句话说,你拿到的是原生 Iceberg 表的性能,同时具备行列级管控。这正是过去把细粒度权限做在库外时最难两全的地方——要么产生副本,要么打断下推。

代价则是引入了一个此前不存在的假设:目录必须相信引擎会老实执行。

这就解释了掩码为什么要精确到字节。既然执行方不是自己,那么"两个独立实现必须产出完全一样的结果"就不能是期望,而必须是规范。show-last-4 到底保留几位、被遮住的部分用什么字符填、NULL 输入该输出什么(规范规定:所有动作遇到 NULL 输入必须输出 NULL)——这些全都不能留给实现者自由发挥。这个模型只在所有参与者对每个掩码的含义达成逐字节共识时才成立。

配套的是 fail-closed 语义。如果一个声称支持 Read Restrictions 的读取器拿到了它无法完整执行的东西——认不出的掩码、解析不了的表达式、不知道怎么处理的投影——它必须让查询失败。规范把不允许的三种行为列得很清楚:不许返回原始数据,不许返回部分数据,也不许返回空结果。

第三条最值得停一下。前两条很好理解,空结果为什么也不行?因为空结果会被误读成"这个人本来就没有可看的数据",而不是"这次执行失败了"。用规范作者的话说,对一个治理原语来说,沉默是唯一不可接受的失败模式。

三、规范明确没有定义的那一部分

到这里逻辑是自洽的:目录下发指令,引擎照做,做不到就报错。但有个问题绕不过去,而它在 PR 评审里被直接问了出来:

我们对目录该如何拒绝不可信引擎有任何指导吗?如果我理解没错,一个不可信的引擎完全可以连上目录、看到这些读取限制、然后无视它们,对吧?

规范作者的回答是:目录可以返 403;信任可以通过 mTLS 或 OAuth 的 on-behalf-of 流程建立;但这是目录和客户端之间的事,不写进规范。目录有完全的自主权决定什么时候、给谁下发读取限制。

追问接着来了:那如果有人把可信引擎的证书拿去配在一个不可信的引擎上呢?回答同样坦率——如果连证书泄露都要质疑,那密码和 OAuth 凭证也一样不安全,保护凭证是可信引擎自己的责任。

这个回答在工程上站得住,但它把边界划清楚了:规范交付的是一套可互操作的指令格式,不是一套可验证的执行保证。 策略第一次做到了跟着表走,"谁保证它被执行"这件事却被明确留在了规范之外,成了目录与引擎之间一份平台特定的双边协议。

评审里也有人提了一个更硬的方案:能力协商。让客户端在请求侧用一个头(比如 Iceberg-Supported-Capabilities: read-restrictions)主动声明自己支持,目录对没有声明支持的客户端不得下发读取限制,而是在该主体本应受限时直接返回 403。提案者的理由很漂亮——这把执行责任放到了能够验证它的那一侧。 目前规范里还缺一个让服务端确认"客户端确实理解了这些限制而不是忽略了它们"的机制。

还有一处小但真实的口子:缓存相关的条款用的是 should not 而不是 MUST NOT。对一个每次调用结果都因人而异的接口来说,这个措辞的强度值得再掂量。

四、嵌套类型上的那个决定

规范里有个技术细节,我觉得比主功能更能说明这份规范的做事方式。

如果一个投影作用在嵌套类型字段上(structlistmap),那么同一份 ReadRestrictions 里就不允许再有投影作用于它的任何子字段;读取器一旦收到这样的响应,必须让查询失败。

为什么不干脆定义一个优先级规则,比如"最内层优先"?评审里的讨论给出了两条依据:ANSI SQL 对嵌套列的掩码没有任何定义;而 Apache Ranger 那个"澄清嵌套类型列掩码处理"的 issue,从 2021 年挂到现在还开着。也就是说,这件事在整个行业里都没有共识。

于是提出者给了一句我很喜欢的判断:禁止是可逆的选择,定义优先级不是。 现在写 MUST NOT,将来真发现了合理用例,放开限制是安全的、非破坏性的;反过来,先拍一个优先级语义,之后想改就是破坏性变更。

在没有共识的地方先关门,而不是急着定一个语义——对一份要被多个独立实现依赖的规范来说,这是比补齐功能更重要的品质。

五、ICE 观察

🔧 技术视角:引擎选型的标准要多一条

过去把细粒度权限做在库外,无非几种路子:建一层视图、跑一遍脱敏 ETL、或者接一个第三方策略引擎。它们的共同代价是要么产生副本、要么打断谓词下推,因此性能和治理总在互相让步。

Read Restrictions 提供了第三条路,但它给引擎选型加了一条新标准:你的引擎在不在那份"可信名单"里。 以后评估一个查询引擎,除了性能和成本,还要看它有没有实现 Read Restrictions、掩码实现是否逐字节合规、以及在无法执行时是不是真的 fail-closed 而不是静默返回空表。

更值得注意的是 Snowflake 在博客里顺带提到的两项额外保障:plan hiding(隐藏执行计划)和防谓词重排攻击。这暗示了一个新的攻击面——即使引擎老老实实执行了掩码,如果它把执行计划暴露给用户,或者允许谓词被重新排序,攻击者仍有可能通过构造查询反推出被掩码的值。执行正确不等于不泄漏,这一层的攻防才刚开始。

至于落地节奏,规范目前的状态是:API 定义完成,端到端可行性在 Iceberg Generics、Spark、Trino 上都跑通了 POC,规范一致性套件也有了。但引擎的正式支持还没到位,这是官方自己点明的下一步。所以现在适合做技术预研和引擎能力盘点,不适合排进本季度的生产计划。

🔒 合规视角:跨主体场景下最脆弱的恰恰是"信任引擎"

单主体、多引擎的场景,这套模型很合适。企业内部同时跑 Spark、Trino、还有各种自研查询服务,以前每个引擎都要配一遍策略,现在策略在目录里写一次,各引擎统一执行——这是实打实的收益。

但把场景换成跨组织的数据协作,"目录相信引擎"这个假设就变成了最脆弱的一环。我凭什么相信对方机构跑的那个 Spark 会老实执行我下发的行过滤?证书能证明"这是一个被登记过的引擎",但证明不了"它此刻正在正确执行"。这两件事之间的距离,正是可信数据空间里最难跨的那一步。

要补上这一步,需要的不是更强的证书,而是可验证的执行证明——让引擎能够证明自己确实施加了那些限制,而不是仅仅承诺过。这条线上,《机密 AI 的执行点:密钥释放之前,CPU 与 GPU 必须证明彼此绑定》里讨论的硬件互证思路和这里能接上:把"我信任你"替换成"你可以向我证明"。

Read Restrictions 没有走这一步,而且明确说了不打算在规范里走。这不是缺陷,是范围选择——它先把指令格式统一了,这已经是件难事。但对做跨主体流通的人来说,需要清楚自己拿到的是什么:一份统一的语言,不是一份执行保证。

结论

  1. 策略第一次跟着表走了。 loadTable 现在会返回按调用者求值后的行过滤和列掩码,策略留在目录、决策随表流到引擎。这终结了"同一份策略在每个引擎里重写一遍"的局面,也顺带保住了性能——不需要脱敏副本,谓词下推照常工作。
  2. 掩码钉到字节和 fail-closed 是这套模型的地基,不是细节。 执行方不再是策略的所有者,所以九种掩码的语义必须逐字节一致,执行不了时必须让查询失败——不许返回原始数据、部分数据,也不许返回空结果。对治理原语来说,沉默是最坏的失败模式。
  3. 规范给的是可互操作的指令格式,不是可验证的执行保证。 目录凭什么相信引擎会照做,被明确留在规范之外,靠 mTLS 或 OAuth 委派构成的双边协议解决。评审中"能力协商 + 对不支持的客户端直接 403"的提案更硬,因为它把执行责任放到能验证的那一侧,但目前还没进规范。
  4. 现在该做的是盘点,不是排期。 引擎的正式支持尚未到位,端到端只有 POC。可以先做两件事:盘一遍自家引擎的实现进度和 fail-closed 行为,以及确认它有没有 plan hiding、防谓词重排这类"执行正确之外"的保护。而如果你的场景是跨组织协作,那么在证书之外,还得想清楚可验证执行那一层怎么补。