Skip to content

ADR-0056 D4 的 RLS authoring gate 从未接线:isSupportedRlsExpression 没有任何非测试消费者 #4983

Description

@xuyushun441-sys

发现于 #4698 的实现过程(packages/lint 侧已落地 sharing-rule 那条,见 PR)。这是同一缺陷类的姊妹面,但修不进那个 PR,原因写在下面。

事实

isSupportedRlsExpression (packages/plugins/plugin-security/src/rls-compiler.ts) 的 TSDoc 明确说明它存在的唯一理由:

ADR-0056 D4: exposed so an authoring-time gate (objectstack compile) can REJECT a predicate the runtime would silently drop — the class of bug where owner == current_user.name (==, unsupported) compiled to nothing and left an object unprotected. A false here means "this predicate will never enforce".

仓内消费者盘点(grep -rn isSupportedRlsExpression --include=*.ts packages/):

  • packages/plugins/plugin-security/src/rls-compiler.ts — 定义处
  • packages/plugins/plugin-security/src/security-plugin.test.ts — 它自己的单测
  • packages/qa/dogfood/test/authz-conformance.matrix.ts — 一致性矩阵里的一行描述文字

即:那个 authoring gate 从来没有被任何 authoring 命令调用过。函数本身就是一个 declared-but-never-read 的实例 —— 而且它恰好是为了修 declared-but-never-read 而写的。

后果

stack.permissions[].rowLevelSecurity[].using / .check 是可授权面(PermissionSetSchema.rowLevelSecurity)。一条不可下推的谓词在运行期走 RLSCompiler.compileExpression → compileCelToFilter 返回 !ok → return null → 单策略路径变成 RLS_DENY_FILTER(fail closed)。

所以它不是安全漏洞,而是一条看起来在授权、实际在拒绝的策略:管理员写了一条 policy,用户被一律拒访,现场没有任何指向这条 policy 的诊断。os validate / os build / os lint 全绿。

为什么没有随 #4698 一起做

判据本身是可机械判定的(和 sharing rule 那条同一个编译器),但判据的实现位置不对:

  • RLS 的决策过程是 isSupportedRlsExpression,它先经 sqlPredicateToCel 把遗留 SQL-ish 子集(= → ==,IN → in)桥接成 canonical CEL,再问 isPushdownableCel。
  • sqlPredicateToCel 和 isSupportedRlsExpression 两个都住在 plugin-security,那是一个 runtime。@objectstack/lint 的包契约是 "Depends on @objectstack/spec; never on a runtime"。
  • 于是 lint 只有两条路:import 一个 runtime(禁止),或者把桥接逻辑抄一份到 lint(把唯一判据 fork 成两份 —— 正是 validate/lint have no check for "declared but never read" metadata — three instances found in one app in a day #4698 那条规则的文档开头拒绝做的事)。

抄一份尤其危险:sqlPredicateToCel 是一个正则重写,它的 IN / = 边界条件(引号内字面量不重写、对 CEL 输入幂等)是这条规则判绿判红的分界线。两份实现只要漂移一格,lint 就会在 authoring 时否掉运行期其实能执行的策略 —— 误伤方向,比漏报更糟。

建议做法(engine / security 车道)

  1. 把 sqlPredicateToCel 上提到 @objectstack/formula(它已经是 isPushdownableCel 的家,而且 lint 已经依赖它)。plugin-security 从那里 re-export 或直接引用,保持一份定义。ADR-0058 D1 说的就是 "a single canonical shape gate",桥接属于那个 gate 的一部分。
  2. 在 packages/lint 加一条规则,判据 = isPushdownableCel(sqlPredicateToCel(expr)),覆盖 permissions[].rowLevelSecurity[].using 和 .check,以及对象上直接授权的 objects[].rowLevelSecurity[](validateOrgAxisRedLines 已经在走这两条路径,可以照抄遍历)。
  3. 严重度建议 error,理由同 sharing-rule 那条:没有任何一种读法下这条 policy 会按作者写的那样授权。
  4. 落地前先跑一遍全仓实测。我在实现 validate/lint have no check for "declared but never read" metadata — three instances found in one app in a day #4698 时已经扫过一次:examples/ 与 packages/plugins/plugin-security/src/objects/default-permission-sets.ts 里现存的每一条 using / check 都能下推,新 gate 不会让任何现有代码变红。

验证脚本(实测,非推断)

import { isPushdownableCel } from '@objectstack/formula';
isPushdownableCel('owner == current_user.name');      // => { ok: true }  (#4171 时代的例子如今能下推)
isPushdownableCel('size(record.tags) > 0');           // => { ok: false, reason: 'unsupported' }
isPushdownableCel("record.account.region == 'EU'");   // => { ok: false, reason: 'unsupported' }

相关:#4698(母议题,declared-but-never-read 的模式)、ADR-0056 D4、ADR-0058 D1。

Activity

  1. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    认领(PM 分派)

    按本单正文的顺序执行:第一步把 sqlPredicateToCel 从 plugin-security 提升进 @objectstack/formula(plugin-security 改为消费,保持单一定义);第二步在 lint 侧按 #4985 的同款模式(调用消费者本人的判定 isSupportedRlsExpression,经 formula)为 rowLevelSecurity[].using/.check 立 authoring gate。这同时终结正文里那个讽刺:该函数从此有了真正的消费者。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    审核通过 — PR #5008 已取消草稿并挂上 auto-merge(SQUASH);两步都落地,那个讽刺正式终结

    isSupportedRlsExpression 从"零消费者的 authoring-gate 用函数"变成了真 gate 的判据,且判据是消费者本人的那次调用 —— RLSCompiler.compileFilter 决定丢弃与否用的同一个函数,两边按构造同一个布尔。plugin-security 侧还换上了更强的消费侧断言:corpus 测试断言"编译器真正丢弃的集合 == 该函数拒绝的集合" —— 这句话正是新 gate 的成立前提,现在被钉住了。

    报错文案写的是实读出来的后果,读/写两路分开:using 被丢弃且是唯一策略 → RLS_DENY_FILTER 匹配零行;check 被丢弃 → post-image 变哨兵 → PermissionDeniedError。fail-closed 不是漏洞 —— 是一条"看起来在授权、实际一律拒绝"的策略,而三个 authoring 命令今天全绿。这正是 gate 的存在理由。

    两处对模板的必要偏离,判断都对:

    1. parse-error 没让给 validateStackExpressions(feat(lint): 判红「声明了但运行期只会跳过」的 sharing-rule condition (#4698) #4985 是让的)—— 因为实测那条规则根本不走 rowLevelSecurity,让给它等于让给没有人;而且它判不对:owner_id = current_user.id 同时是 CEL 语法错和一条能正常工作的 RLS 谓词(桥接先跑)。收成自己的第二个 id,修法确实不同。
    2. gate 范围比议题正文窄:议题建议覆盖 objects[].rowLevelSecurity[],但 ObjectSchema 根本没有这个键 —— 照抄会新增一条任何 spec 合法 stack 都到不了的惰性分支,即 validateOrgAxisRedLines 读的 sharing-rule 键是 spec 拒收的:ADR-0105 D6 ① 在 criteria 路径上从不触发 #4984 的缺陷本身。拒绝复制刚修过的错误形状,并用测试钉住该面判绿。

    依赖方向用测试钉而非注释(import 说明符集合 + package.json dependencies 断言)—— 理由说得准:只在构建期出错的依赖方向不会产生任何失败断言。搬迁逐字、单测随迁、不留 re-export(单一地址,不是单一定义加转发口)。

    与 #4991 的注册处双写没有发生(它没碰 authoring-rules.ts),merge main 后 lint 1052 测试全绿。全仓 9 条真实 RLS 谓词全部判绿;桥接最锋利的边(引号内 = / IN)单独钉住。

    衍生 #5009 已入队:validateOrgAxisRedLines 还剩三条 spec 合法 stack 到不了的别名分支(含整段 objects[].rowLevelSecurity 死代码)—— #4984 只修了 sharing rule 那一半,危险在误导下一个作者相信那是真实授权面。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions