fix(analytics): read scope 的 $null / $exists 非布尔比较数改为拒收,不再按真值性编成相反的谓词 (#6387) - #6443
Merged
Merged
Conversation
…反的谓词 (#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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
hotlong
marked this pull request as ready for review
August 7, 2026 19:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6387
按 #5347(
$null)/ #5369($exists)在driver-sql面确立的先例,把非布尔比较数的按声明拒收推到 RLS 编译器read-scope-sql.ts。分诊已定第三档(适用既有先例),不含新契约取舍。实测:立单的前提成立,逐字节复现
在
origin/main(5faa23ca3,已含 PR #6390)上直接调compileScopedFilterToSql,aliast。发射器读的是val ? … : …—— 真值性,不是@objectstack/specFieldOperatorsSchema声明的z.boolean():{ 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.getReadScope→plugin-security.getReadFilter→computeRlsFilter上,没有任何一处能产出非布尔的$null:RowLevelSecurityPolicySchema把using/check声明为z.string()(CEL 谓词)。实测safeParse一个对象:{"expected":"string","code":"invalid_type","path":["using"],"message":"Invalid input: expected string, received object"}。@objectstack/formula的cel-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,不会留下游离比较数。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-sharing的buildReadFilter(只产{owner: id}/$in/$or/{id:'__deny_all__'})、controlled-by-parent({fk: {$in: […]}})、deny 哨兵。仍然开着的那条,如实记:
getReadScope是AnalyticsPluginOptions上有文档的公开扩展点(plugin.ts),宿主自带的 read scope(来自 JSON 配置,或没走类型检查的 JS)与本编译器之间没有任何闸。本单也确认了立单时的另一半测量:plugin-security全路径FilterConditionSchema/safeParse命中数为 0。这对定级意味着什么: 它不是今天由库存 metadata 驱动的活体绕过(#5347 那种 P0 形态在这里没有复现),而是一个 fail-closed 编译器上的契约缺陷 —— 今天不可从库存 metadata 触达,且没有任何结构性的东西挡住下一个生产者。处置不受影响:先例定的是处置(拒收),不是严重度;在编译器处拒收,才让「声明为布尔」等于「强制为布尔」,与谁写这条 scope 无关。
修法
assertBooleanFlagComparands落在compileField,紧挨 [finding]undefined比较数在仓内有五种读法 —— #6050 只在 driver-sql/turso 落了拒收,formula 读作「键缺失」、read-scope-sql 编成= NULL、driver-memory 读作 null #6125 的undefined闸。两道闸作用域互不相交(assertDefinedComparands按名字跳过这两个算子),所以谁也盖不住谁的措辞;hasOwnProperty而非in(对齐 driver-sql 的孪生实现,继承来的键不该触闸,而{ $null: undefined }算数)。两臂随之从val ? …改为val === true ? …,在闸之下是穷尽的二选一。{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240):driver-sql给孪生实现两条措辞,是因为各自要指名自己发射器默认倒向哪边;本模块只有一条规则(真值性)同时管着两个算子,两者失败方式完全一样,所以一条措辞才是诚实的写法。只有算子名与path不同,并有一条断言把「只有这两处不同」钉死。READ_SCOPE_COMPILE_FAILED/ 500),不是$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 的INVALID_FILTER/ 400:read scope 是平台编译产物,报 400 等于让调用方去修一个他既没写、也改不动的东西。继承的是处置,不是信封。nullValueSatisfiesOperator的$null/$exists两臂从真值性(Boolean(value)/!value)改为恒等(value === true/value === false)。每张极性表钉的是它自己发射器的拼写($not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 / 非否定路径上的$ne/$nin/$notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了$not) #5298),只改发射器不改表,不变量会安静地断在定义处。这条有意的差异消失后,本编译器与driver-sql的同名表第一次逐臂一致 —— 差异没了,是因为它的成因没了。极性表的改动无法从模块外独立观察:闸把两种拼写会分歧的值全部拒掉,而幸存下来的
true/false上,真值性与恒等给出同一答案(driver-sql的$exists臂当年也是这么记的)。所以测试钉的是它的前提,并在文件里写明为什么 —— 布尔行经$not重写的极性逐字节不变(收紧无害的对照组),以及非布尔在$not重写里无论被归成哪一档,重写后的叶子仍会走到compileField被拒,表的判读一定被丢弃。凑一条「证明表改了」的断言只会因为错误的原因变绿。⛔ 一字未动
$null: true/false、$exists: true/false的 SQL 逐字节不变(IS NULL下降正是 RLS 用来圈无主行的写法,也是 CEL 唯一能产出的四种形状)—— 有自己的对照组。undefined比较数改为拒收,不再编成绑了 undefined 的合法 SQL (#6125) #6390 的 11 行NULL_CONTROL(比较数位置上的null)全部保持绿。driver-sql/driver-turso、packages/spec(声明已是z.boolean(),够用)、以及本包的where门strategies/filter-normalizer.ts(service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386 在飞的另一扇门)均未触碰。#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 test:67 files / 1345 tests passedpackages/rest的analytics-read-scope-refusal-envelope+rest-4xx-message-truncation(19 passed)、packages/runtime的analytics-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-bytes、check:error-code-casing、check:route-envelope、check:empty-changeset、check:doc-authoring全绿;eslint 无输出read-scope-refusal-envelope.test.ts12 行 / 11 site → 14 行 / 12 site(一道闸、两个触发器,与quoteIdent的 alias/field 分裂同形)changeset:
@objectstack/service-analyticspatch,按 Observable behaviour change 写法声明今天靠静默翻转在跑的 read scope 将开始 500 拒收。Generated by Claude Code