Skip to content

fix(analytics): read scope 的 $null / $exists 非布尔比较数改为拒收,不再按真值性编成相反的谓词 (#6387) - #6443

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6387-rls-boolean-comparand-reject
Aug 7, 2026
Merged

fix(analytics): read scope 的 $null / $exists 非布尔比较数改为拒收,不再按真值性编成相反的谓词 (#6387)#6443
hotlong merged 1 commit into
mainfrom
claude/issue-6387-rls-boolean-comparand-reject

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6387

#5347$null)/ #5369$exists)在 driver-sql 面确立的先例,把非布尔比较数的按声明拒收推到 RLS 编译器 read-scope-sql.ts。分诊已定第三档(适用既有先例),不含新契约取舍。

实测:立单的前提成立,逐字节复现

origin/main5faa23ca3,已含 PR #6390)上直接调 compileScopedFilterToSql,alias t。发射器读的是 val ? … : … —— 真值性,不是 @objectstack/spec FieldOperatorsSchema 声明的 z.boolean()

read scope 编译结果
{ owner_id: { $null: "false" } } "t"."owner_id" IS NULL ⛔ 与作者写的意思相反
{ owner_id: { $null: "true" } } "t"."owner_id" IS NULL
{ owner_id: { $null: 0 } } "t"."owner_id" IS NOT NULL
{ owner_id: { $null: null } } "t"."owner_id" IS NOT NULL
{ owner_id: { $null: undefined } } "t"."owner_id" IS NOT NULL
{ owner_id: { $exists: "false" } } "t"."owner_id" IS NOT NULL ⛔ 与作者写的意思相反
{ owner_id: { $exists: 0 } } "t"."owner_id" IS NULL
{ owner_id: { $exists: "no" } } "t"."owner_id" IS NOT NULL

两行 ⛔ 是要害:字符串 "false"真值,于是它落在它被写下来所要表达的 false对面{ $exists: "false" } 写来表示「没有 owner 的行」,编出来是「 owner 的行」—— 在一个自述「A read-scope predicate must never be silently dropped、fail-closed」的模块里,这是加宽(admit 了策略要排除的行),方向与 #6125 那一格(fail-closed、匹配零行、只是安静)相反。

reachability_measured:库存 metadata 走不通(三道闸各自独立关死)

这是本单点名的决定性测量,我把它跑通了 —— 结论是,如实回报,定级依据是测量而不是立单时的措辞。进料口链条 ctx.getReadScopeplugin-security.getReadFiltercomputeRlsFilter 上,没有任何一处能产出非布尔的 $null

  1. 规则元数据本身不收 FilterCondition。 RowLevelSecurityPolicySchemausing / check 声明为 z.string()(CEL 谓词)。实测 safeParse 一个对象:{"expected":"string","code":"invalid_type","path":["using"],"message":"Invalid input: expected string, received object"}
  2. CEL 下降根本不发射这个形状。 @objectstack/formulacel-to-filter.ts 只在两处发射 $null,两处的比较数都是硬编码布尔== null{ $null: true }!= null{ $null: false }),$exists 一次都不发射。实测 12 条 CEL:owner_id == null{"owner_id":{"$null":true}}owner_id != null{"owner_id":{"$null":false}}owner_id == current_user.nothing(变量解析为 null)→ unresolved-variable,策略被丢弃、fail-closed,不会留下游离比较数。
  3. 绕开 schema 也到不了。 把裸对象当谓词喂给 RLSCompiler.compileFilter,在 sqlPredicateToCel 里抛 TypeError: expression.replace is not a function,被 getReadFilter 的 catch 变成 RLS_DENY_FILTER。存成 JSON 字符串则通过 schema,但 CEL 解析失败 → null → deny 哨兵。两条路都终止于 fail-closed。

其余 read scope 生产者同样不含这两个算子:Layer 0 租户过滤(computeTenantLayer0Filter)、plugin-sharingbuildReadFilter(只产 {owner: id} / $in / $or / {id:'__deny_all__'})、controlled-by-parent({fk: {$in: […]}})、deny 哨兵。

仍然开着的那条,如实记: getReadScopeAnalyticsPluginOptions有文档的公开扩展点plugin.ts),宿主自带的 read scope(来自 JSON 配置,或没走类型检查的 JS)与本编译器之间没有任何闸。本单也确认了立单时的另一半测量:plugin-security 全路径 FilterConditionSchema / safeParse 命中数为 0

这对定级意味着什么:不是今天由库存 metadata 驱动的活体绕过(#5347 那种 P0 形态在这里没有复现),而是一个 fail-closed 编译器上的契约缺陷 —— 今天不可从库存 metadata 触达,且没有任何结构性的东西挡住下一个生产者。处置不受影响:先例定的是处置(拒收),不是严重度;在编译器处拒收,才让「声明为布尔」等于「强制为布尔」,与谁写这条 scope 无关。

修法

⚠️ 极性表这一半「测不出来」,如实记而不是编一条证据

极性表的改动无法从模块外独立观察:闸把两种拼写会分歧的值全部拒掉,而幸存下来的 true / false 上,真值性与恒等给出同一答案(driver-sql$exists 臂当年也是这么记的)。所以测试钉的是它的前提,并在文件里写明为什么 —— 布尔行经 $not 重写的极性逐字节不变(收紧无害的对照组),以及非布尔在 $not 重写里无论被归成哪一档,重写后的叶子仍会走到 compileField 被拒,表的判读一定被丢弃。凑一条「证明表改了」的断言只会因为错误的原因变绿。

⛔ 一字未动

#6390 钉的那两行,按新语义重写

read-scope-undefined-comparand.test.ts{ $null: undefined }IS NOT NULL / { $exists: undefined }IS NULL 两行,正是本单要改的地方,改红是预期。重写后:两者都拒收,但拒收的理由换了 —— undefined 在这里不是「比较数位置」(那道闸至今仍按名字跳过这两个算子,#6125 的说法一字未动),而是落在声明的布尔域之外,对一个 flag 而言这是更贴切的诊断。测试同时断言它拿到的不是 #6125 的措辞。

反向验证(方向先判后跑:预期为红)

把闸关掉、两臂还原成 val ? … 复跑:39 条新 pin 转红,失败信息逐条引出改动前的下降结果(it still COMPILED — pre-#6387 this lowered to "t"."d" IS NULL),正好回证上表的 wasSql 一列。对照组在还原状态下保持绿(17 条纯对照断言全过),证明它确实是「改动前后都过」的对照组而非改动本身。

验证

  • pnpm --filter @objectstack/service-analytics test67 files / 1345 tests passed
  • 消费半径:packages/restanalytics-read-scope-refusal-envelope + rest-4xx-message-truncation(19 passed)、packages/runtimeanalytics-query-read-scope-withhold(10 passed)
  • tsc --noEmit:改动的 4 个文件零错误(该包 10 条既有报错全在无关测试文件里);pnpm build 成功
  • 根门:check:type-check-debt先全量 pnpm build 再判 —— 新 worktree 首跑因未建依赖假红 9 个包,均与本包无关;建完后 OK — none above its recorded number)、check:nul-bytescheck:error-code-casingcheck:route-envelopecheck:empty-changesetcheck:doc-authoring 全绿;eslint 无输出
  • 拒收清单棘轮同步:read-scope-refusal-envelope.test.ts 12 行 / 11 site → 14 行 / 12 site(一道闸、两个触发器,与 quoteIdent 的 alias/field 分裂同形)

changeset:@objectstack/service-analytics patch,按 Observable behaviour change 写法声明今天靠静默翻转在跑的 read scope 将开始 500 拒收。


Generated by Claude Code

…反的谓词 (#6387)

`compileOperator` 用 `val ? … : …` 读这两个算子 —— 真值性,而不是
`@objectstack/spec` `FieldOperatorsSchema` 声明的 `z.boolean()`。实测
`{$null: "false"}` 编成 `IS NULL`、`{$exists: "false"}` 编成 `IS NOT NULL`,
都与作者写下来的意思相反:字符串 `"false"` 是真值,落在它所要表达的 `false`
的对面。在一个自述 fail-closed 的 RLS 下降点上,这是加宽 —— admit 了策略要
排除的行。

按 #5347(`$null`)/ #5369(`$exists`)在 driver-sql 面确立的先例拒收,理由
逐字适用;信封复用本模块自述唯一的 `READ_SCOPE_COMPILE_FAILED` / 500。两个
算子共用一条措辞(#5240),只有算子名与 path 不同,并有断言钉死这一点。

极性表同 PR 改:`nullValueSatisfiesOperator` 的两臂从真值性改为恒等 ——
每张极性表钉的是它自己发射器的拼写(#5146 / #5298),只改发射器不改表,
不变量会安静地断在定义处。

触达性实测:库存 metadata 走不通(RLS 策略的 `using` 是 `z.string()` CEL;
CEL 下降只发射硬编码布尔的 `$null`,`$exists` 一次都不发射;绕开 schema 的
裸对象在 `sqlPredicateToCel` 抛错并被 catch 成 deny)。仍开着的是
`AnalyticsPluginOptions.getReadScope` 这个公开扩展点。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 7:01pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 19:43
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 3cc8676 Aug 7, 2026
25 checks passed
@hotlong
hotlong deleted the claude/issue-6387-rls-boolean-comparand-reject branch August 7, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

read-scope-sql 的 $null / $exists 按真值性读比较数 —— {$null: "false"} 编成 IS NULL,#5347 / #5369 的先例没推到 RLS 编译器

2 participants