Skip to content

Commit 2cca98b

Browse files
os-zhuangclaude
andauthored
fix(service-analytics): read scope 的 { $not: {} } 不再整表放行,$not 改为 NULL-safe (#5297) (#5326)
`read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的唯一通道(ADR-0021 D-C),它以空字符串表示布尔常量 TRUE。两处忘掉「空串是一个值」的地方,正是本单 要修的两条分叉。 分叉二(要害):`compileNode({})` 返回空串 → `if (inner)` 为假 → 整条 `$not` 不 产出子句 → `compileScopedFilterToSql` 返回 '' → `applyReadScope` 的 `if (!sql) return;` 接手 → 生成的 SQL 里没有 WHERE。语义为 `NOT TRUE ≡ FALSE` (什么都不给看)的 read scope,实际整表放行。同一段循环里 `$and` / `$or` 的空数组 一直是 fail-closed 抛错的,只漏了 `$not` 这一格。现在编译为恒假子句 `1 = 0`,与 driver-sql 在 #5134 / PR #5243 上的口径一致。 同源、方向相反的一处一并修正:`$or` 的空析取项 `{}` 以前被 `.filter(s.length > 0)` 丢掉,`{ $or: [{}, { a: 1 }] }` 收紧成 `a = 1`。`{}` 是 TRUE 析取项,TRUE 吸收整个 析取,现在整条 `$or` 为 TRUE。被丢弃分支的绑定值随之丢弃 —— 每个子节点先编译进自己 的 params 缓冲、存活了才提交,否则 `params` 里会留下没有 `?` 消费的值,把后面每一个 占位符错位到别人的值上(一条绑错租户 id 的 read scope 比一条过宽的更糟)。 分叉一:`$not` 改为 NULL-safe。语义由 #5146 拍板,PR #5296 已在 driver-sql 落地; 本文件此前是仓内最后一个按三值逻辑回答 `$not` 的 SQL 家族实现。操作数在取反前先被 改写成全域谓词(`nullSafeNegationOperand`),守卫按算子极性下推到叶子而不是挂在 `NOT` 旁边 —— 操作数一嵌套,顶层的 `OR col IS NULL` 会把 JS 家族排除的行重新放进来。 极性表与 driver-sql 逐条对齐,两处刻意的差异写在 TSDoc 里:`$null` / `$exists` 按本 文件 emitter 的真值性读取(driver-sql 的 emitter 按与 false 的恒等读取),以及本文件 多出的 `$between` 走正向比较的默认值。 `$not` 路径以外一个字符未动,fail-closed 的全部保证原封不动 —— 未知算子、嵌套关系 值、裸数组、不安全标识符、非 filter 节点的操作数,以及 `$and: []` / `$or: []` 的空 组合子(#5322 的独立裁定)统统照旧抛错。 测试:`read-scope-not-null-safe.test.ts` 是本单自带的 pin。既有的 `read-scope-sql-conformance.test.ts` 跑的 `FILTER_LOGIC_CASES` 刻意不含 null 行、 也不含布尔单位元,两条缺陷因此都在现有门禁下绿着。新用例在 sql.js 上执行取行, `{$not:{}}` 一条断言到 `NativeSQLStrategy.generateSql` 这一层 —— 缺陷正在编译器与 `applyReadScope` 的接缝处,只测 `compileNode` 抓不到。 Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04fab5e commit 2cca98b

3 files changed

Lines changed: 739 additions & 11 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): 分析查询的 RLS read scope 不再被 `{ $not: {} }` 整表放行,`$not` 改为 NULL-safe
6+
7+
**这是一次安全相关的行为变更,涉及分析查询的可见行集合。请读完再升级。**
8+
9+
### 变更一(要害):`{ $not: {} }` 的 read scope 以前**完全不加 WHERE**,整表可见;现在是零行
10+
11+
`read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的**唯一**通道(ADR-0021 D-C),
12+
被 `NativeSQLStrategy.applyReadScope` 与 `ObjectQLStrategy` 用来给分析查询加可见性约束。
13+
它以空字符串表示「无约束」(布尔常量 TRUE)。`compileNode({})` 返回空串,于是:
14+
15+
```
16+
compileNode({}) → '' → if (inner) 为假 → $not 不产出任何子句
17+
→ compileScopedFilterToSql 返回 ''
18+
→ applyReadScope 的 `if (!sql) return;` 接手
19+
→ 生成的 SQL 里没有 WHERE
20+
```
21+
22+
一条语义为 `NOT TRUE ≡ FALSE`(**什么都不给看**)的 read scope,实际效果是**整张表都给看**。
23+
同一段循环里 `$and` / `$or` 的空数组一直是 fail-closed 抛错的,只漏了 `$not` 这一格。
24+
25+
修复后 `{ $not: {} }` 编译为恒假子句 `1 = 0`,`applyReadScope` 照常拼进 WHERE,返回零行 ——
26+
与 driver-sql 在 #5134 / PR #5243 上的口径一致。
27+
28+
**升级影响:** 如果你的 RLS 策略(或 `cel-to-filter.ts` 降解出的 CEL 规则)在某条路径上
29+
产出过 `{ $not: {} }`,该对象的分析查询此前是**无边界**的,现在会返回零行。行数从「全部」
30+
掉到「零」不是本次引入的收紧,而是那条策略本来就该有的答案 —— 请核对策略本身。
31+
32+
同源、方向相反的一处一并修正:`$or` 的空析取项 `{}` 以前被 `.filter(s => s.length > 0)`
33+
丢掉,`{ $or: [{}, { a: 1 }] }` 收紧成 `a = 1`。`{}` 是 TRUE 析取项,TRUE 吸收整个析取,
34+
所以现在整条 `$or` 为 TRUE(无约束)。被丢弃分支的绑定值同时被丢弃 —— 否则 `params` 里
35+
会留下没有 `?` 消费的值,把后面每一个占位符都错位到别人的值上。
36+
37+
### 变更二:`$not` 改为 NULL-safe
38+
39+
SQL 是三值逻辑,`WHERE` 只保留 TRUE,所以裸 `NOT ("t"."stage" = ?)` 会把 `stage IS NULL`
40+
的行整批丢掉;`driver-memory`、`formula` 以及 #5296 之后的 `driver-sql` 都**返回**这些行。
41+
同一条 read scope,普通查询与分析查询给出不同的可见集合。#5146 已由维护者判定以 JS 家族的
42+
答案为准,本次把这个编译器对齐过去 —— 它是仓内最后一个按三值逻辑回答 `$not` 的 SQL 家族实现。
43+
44+
`$not` 的操作数在取反前先被改写成**全域(total)谓词**:
45+
46+
```sql
47+
-- 之前
48+
NOT ("t"."stage" = ?)
49+
-- 现在
50+
NOT (("t"."stage" IS NOT NULL AND "t"."stage" = ?))
51+
```
52+
53+
守卫**下推到每个叶子**而不是挂在 `NOT` 旁边:操作数一旦嵌套(`$not` 里套 `$or`),顶层的
54+
`OR col IS NULL` 会把 JS 家族排除的行重新放进来。守卫方向**逐算子**判定,不是一刀切 ——
55+
`{ $not: { a: { $ne: 5 } } }` 语义是「a 就是 5」,无条件加 `OR a IS NULL` 会把 scope 排除的
56+
行交回去,正是本次要避免的静默放松。所以 `$ne` / `$nin` / `$notContains` 用
57+
`col IS NULL OR (…)`,`$eq` / `$in` / `$gt` / `$between` / `$contains` 一族用
58+
`col IS NOT NULL AND (…)`,而 `$null` / `$exists` / `$eq: null` / `$ne: null` 本就是全域谓词,
59+
一个字节都不加。
60+
61+
**升级影响:** 形如 `{ $not: { stage: 'won' } }` 的 read scope,以前**不返回** `stage` 为
62+
NULL 的行,现在**返回**它们 —— 分析查询的行数与图表数值会随之变化。这是把分析侧对齐到其余
63+
后端,不是新增的放宽。
64+
65+
### 不变的部分
66+
67+
`$not` 路径以外一个字符都没动:普通比较仍然编译成原样的 SQL。fail-closed 的全部保证原封不动
68+
——未知算子、嵌套关系值、裸数组、不安全标识符、非 filter 节点的 `$not` 操作数,以及
69+
`$and: []` / `$or: []` 的空组合子(那一格是 #5322 的独立裁定)统统照旧抛错。

0 commit comments

Comments
 (0)