Skip to content

fix(analytics): enforce read scope on the ObjectQL aggregate path (#3597)#3601

Merged
os-zhuang merged 2 commits into
mainfrom
fix/analytics-objectql-read-scope-3597
Jul 27, 2026
Merged

fix(analytics): enforce read scope on the ObjectQL aggregate path (#3597)#3601
os-zhuang merged 2 commits into
mainfrom
fix/analytics-objectql-read-scope-3597

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3597.

问题

ObjectQLStrategy 从不消费 getReadScope,导致走该路径的 analytics 查询无任何 RLS/租户谓词 —— 已认证用户拿到的是在所有租户行上算出来的聚合。

两层防御同时失效,不是纵深防御下的单点缺口:

  1. analytics 层:getReadScope 在 strategies 里只出现在 native-sql-strategy.ts:200-201;objectql-strategy.ts 零命中。而 spec 契约(spec/src/contracts/analytics-service.ts:262-285)明写这个谓词是 MUST
  2. engine 层:executeAggregate 桥不传 ExecutionContext,于是 plugin-security 中间件在 security-plugin.ts:775-781 命中无 principal 的 fall-open return next(),把自己第 939 行往后的 RLS 注入整个跳过。driver 层同样不兜底(sql-driver.ts:2829 无 tenantId 即视为 unscoped/admin),全仓也不存在 ambient context 可以救(plugin-auth 的对应中间件是个 no-op)。

触发面不限于冷门 driver。 NativeSQL 在三种情况下声明失败并回落到本路径:

  • 任意带 granularity 的日期分桶查询(native-sql-strategy.ts:30)—— 最常见的 dashboard 形状,Postgres/SQLite 上照样命中;
  • RAW_SQL_UNSUPPORTED(in-memory driver);
  • external/federated 对象(ADR-0062 D6)。

修复

1. 把 read scope AND 进 filter(withReadScope)

$and 组合,不做 key 合并。查询自带的 filter 和 scope 可能命名同一个字段(比如 dashboard 自己筛 organization_id),spread 会让调用方输入静默顶掉安全谓词;$and 让这件事结构上不可能。

2. 联表对象 fail-closed(assertJoinedScopesEnforceable)

engine.aggregatewhere 寻址的是基对象。点号成员(account.region)由 engine 经 lookup 字段遍历,但这个调用形状里没有地方挂被联对象的谓词。NativeSQL 能表达(每个 join 一个 alias-qualified WHERE)且已经这么做了;本路径表达不了,于是拒绝而不是跑一个只加了一半 scope 的查询 —— 与 resolveReadScopes(宁可抛错也不发未加 scope 的 SQL)和 compileScopedFilterToSql(宁可抛错也不丢谓词)同一姿态。

只检查查询实际引用到的 join:scope map 是被扫描对象的刻意超集,只看 map 会误杀根本没碰联表的查询。

为什么这个洞活了这么久

现有 dataset-rls-integration.test.ts 每个用例都钉死 queryCapabilities: () => ({ objectqlAggregate: false }) —— 整个 RLS 测试套件从来没走过 ObjectQL 路径

验证

  • 新增 9 个用例直接打 ObjectQL 路径;回退本 PR 的 strategy 改动后 7 个立刻变红(另 2 个断言的是"不该变"的行为:无 provider 时维持 unscoped、只有基对象有 scope 时正常放行)。
  • 覆盖:基础 scope、RAW_SQL_UNSUPPORTED 回落、日期分桶回落、$and 组合、调用方用同字段 filter 试图顶掉安全谓词、同实例双租户隔离、无 provider 契约不变、联表 fail-closed。
  • service-analytics 179 passed(原 170 + 新 9)。
  • 下游 @objectstack/cli + @objectstack/verify 54 个 task 全绿。
  • tsc --noEmit 未引入新错误(该包原有 2 个测试文件的类型错误与本改动无关,已核对为存量)。

未纳入本 PR(已在 issue 记录)

  • 纵深防御第 2 层:让 executeAggregate 契约携带 context,使 engine 侧 RLS 也能生效。要动 packages/spec(executeAggregate options + StrategyContext 透传 ExecutionContext),会碰 api-surface 门,单独开 PR 更干净。本 PR 已从第 1 层彻底堵死泄露。
  • fetchRecordLabels 复用同一条无 scope 的桥(plugin.ts:355):它按 groupBy: ['id', displayField] 返回真实记录显示名。本 PR 修好聚合后,喂给它的 id 已经是加过 scope 的,实际越权已被消除;其自身无 scope 属残留债,单独跟进。
  • generateSql 预览不含 scope:/analytics/sql 的展示性 SQL 无 WHERE。不执行、不构成泄露,单独跟进。

🤖 Generated with Claude Code

)

ObjectQLStrategy never consumed `getReadScope`, so every analytics query
served by that path ran with no RLS or tenant predicate — an authenticated
caller received aggregates computed over every tenant's rows.

Both belts were off at once. The strategy dropped the pre-resolved read
scope, and the engine could not compensate: the `executeAggregate` bridge
passes no ExecutionContext, so plugin-security's principal-less fall-open
(security-plugin.ts:775) skipped its own RLS injection. Only
NativeSQLStrategy was ever wired for ADR-0021 D-C.

Exposure was not limited to exotic drivers. NativeSQLStrategy declines —
routing to this path — on any date-bucketed query (the most common
dashboard shape, on Postgres and SQLite too), on RAW_SQL_UNSUPPORTED, and
on federated objects.

The scope is composed with `$and`, never by key merge, so a caller filter
naming the same field cannot displace the security predicate.

A query referencing a joined object that carries its own scope is now
rejected rather than run partially-scoped: `engine.aggregate`'s `where`
addresses the base object, so a per-join predicate cannot be expressed
there. Failing closed matches resolveReadScopes and
compileScopedFilterToSql.

The gap survived because every case in dataset-rls-integration.test.ts
pins `objectqlAggregate: false` — the RLS suite only ever exercised
NativeSQLStrategy. The new tests cover the ObjectQL path directly; 7 of
the 9 fail without this fix (the other 2 assert unchanged behaviour).

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 27, 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 Jul 27, 2026 9:29am

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): packages/services.

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

  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/plugins/packages.mdx (via packages/services)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services)

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.

@os-zhuang

os-zhuang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

后续项已单独立项:#3602 —— 纵深防御第二层(executeAggregate 桥携带 ExecutionContext,可一并解掉本 PR 对联表查询的 fail-closed 限制)+ fetchRecordLabels / generateSql 两处残留无 scope 调用。

本 PR 保持只做一件事:从 analytics 层堵死泄露。

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(analytics): ObjectQLStrategy 不消费 getReadScope — NativeSQL 回落后聚合查询无 RLS/租户谓词(#2852 修复未覆盖的另一半)

1 participant