fix(analytics): enforce read scope on the ObjectQL aggregate path (#3597) - #3601
Merged
Conversation
) 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Contributor
Author
|
后续项已单独立项:#3602 —— 纵深防御第二层( 本 PR 保持只做一件事:从 analytics 层堵死泄露。 |
…l-read-scope-3597
This was referenced Jul 27, 2026
Open
Merged
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.
Closes #3597.
问题
ObjectQLStrategy从不消费getReadScope,导致走该路径的 analytics 查询无任何 RLS/租户谓词 —— 已认证用户拿到的是在所有租户行上算出来的聚合。两层防御同时失效,不是纵深防御下的单点缺口:
getReadScope在 strategies 里只出现在native-sql-strategy.ts:200-201;objectql-strategy.ts零命中。而 spec 契约(spec/src/contracts/analytics-service.ts:262-285)明写这个谓词是 MUST。executeAggregate桥不传ExecutionContext,于是 plugin-security 中间件在security-plugin.ts:775-781命中无 principal 的 fall-openreturn next(),把自己第 939 行往后的 RLS 注入整个跳过。driver 层同样不兜底(sql-driver.ts:2829无 tenantId 即视为 unscoped/admin),全仓也不存在 ambient context 可以救(plugin-auth 的对应中间件是个 no-op)。触发面不限于冷门 driver。 NativeSQL 在三种情况下声明失败并回落到本路径:
native-sql-strategy.ts:30)—— 最常见的 dashboard 形状,Postgres/SQLite 上照样命中;RAW_SQL_UNSUPPORTED(in-memory driver);修复
1. 把 read scope AND 进 filter(
withReadScope)用
$and组合,不做 key 合并。查询自带的 filter 和 scope 可能命名同一个字段(比如 dashboard 自己筛organization_id),spread 会让调用方输入静默顶掉安全谓词;$and让这件事结构上不可能。2. 联表对象 fail-closed(
assertJoinedScopesEnforceable)engine.aggregate的where寻址的是基对象。点号成员(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 路径。验证
RAW_SQL_UNSUPPORTED回落、日期分桶回落、$and组合、调用方用同字段 filter 试图顶掉安全谓词、同实例双租户隔离、无 provider 契约不变、联表 fail-closed。@objectstack/cli+@objectstack/verify54 个 task 全绿。tsc --noEmit未引入新错误(该包原有 2 个测试文件的类型错误与本改动无关,已核对为存量)。未纳入本 PR(已在 issue 记录)
executeAggregate契约携带context,使 engine 侧 RLS 也能生效。要动packages/spec(executeAggregateoptions +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