结论
ObjectQLStrategy 完全不消费 getReadScope,导致走该路径的 analytics 聚合查询无任何 RLS/租户谓词。两层防御同时失效,不是纵深防御下的单点缺口。
#2852 修的是同一类问题(route 层没传 context),但那次修复只让 NativeSQL 路径拿到了 scope;ObjectQL 路径从未被纳入 ADR-0021 D-C。objectql-strategy.ts 的 git history 里没有任何一次 RLS 相关改动。
两层防御为何同时失效
第 1 层 — analytics 的 getReadScope:未被调用。
getReadScope 在 strategies 里只出现在 native-sql-strategy.ts:200-201:
// native-sql-strategy.ts:193-211 — applyReadScope,只有 NativeSQL 有if(typeofctx.getReadScope!=='function')return;constfilter=ctx.getReadScope(objectName);
objectql-strategy.ts 里 grep -n getReadScope 零命中。它的 filter 只由 normalizeAnalyticsFilters(query)(即用户自己传的 filter)构成,见 objectql-strategy.ts:67-93。
而 spec 契约写得很明确 —— 这个谓词是 MUST:
packages/spec/src/contracts/analytics-service.ts:262-285
"Returns the security predicate that MUST be ANDed into the query for the given object"
第 2 层 — engine 的 security middleware:被 anonymous fall-open 跳过。
plugin.ts:197-208 的 auto-bridge 调用 engine 时不带 context:
// plugin.ts:197-208constrows=awaitengine.aggregate(objectName,{where: filter, groupBy, aggregations, timezone,// ← 没有 context});engine 侧其实是支持的(engine.ts:3339context: mergeReadContext(query?.context, options?.context)),且 aggregate 确实在 middleware 的读路径覆盖范围内(security-plugin.ts:939)。但 context 缺失时,middleware 在到达 RLS 注入之前就 fall-open 返回了:
// security-plugin.ts:775-781 —— 决定性分支if(positions.length===0&&explicitPermissionSets.length===0&&!opCtx.context?.userId){returnnext();// ← 整个安全中间件被跳过,包括 RLS 注入}无 context ⇒ positions=[]、permissions=[]、userId 为空 ⇒ 命中 fall-open ⇒ 第 939 行往后的 read-scope/RLS 注入根本执行不到。
裸 SQL 桥同样无 context(plugin.ts:232-242,engine.execute(knexSql, { args: params }))。
触发条件比预想的宽 —— 不是只在 in-memory driver 上
NativeSQL 声明失败即回落 ObjectQL。三个触发点,最常见的一个和 driver 无关:
- 任何日期分桶查询 ——
timeDimensions[].granularity 一旦有值,NativeSQL 直接 return false:
// native-sql-strategy.ts:30if(query.timeDimensions?.some((td)=>!!td.granularity))returnfalse;
"按月看营收" 是最典型的 dashboard 形状。Postgres/SQLite 上照样漏 —— 这不是 exotic driver 的边角问题。 RAW_SQL_UNSUPPORTED 运行时回落 —— in-memory driver(dev/demo/测试),analytics-service.ts:423-428。- external/federated 对象 —— ADR-0062 D6,
native-sql-strategy.ts:40-50。
复现(失败测试)
放到 packages/services/service-analytics/src/__tests__/ 下跑即可。三个用例全红;同 package 其余 170 个测试全绿,所以不是 harness 问题。
现有 dataset-rls-integration.test.ts 覆盖不到这里的原因:它每个用例都钉死了 queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, ... }) —— RLS 测试从来没走过 ObjectQL 路径。
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.import{describe,it,expect}from'vitest';import{DatasetSchema}from'@objectstack/spec/ui';importtype{ExecutionContext}from'@objectstack/spec/kernel';importtype{FilterCondition}from'@objectstack/spec/data';import{AnalyticsService}from'../analytics-service.js';import{compileDataset}from'../dataset-compiler.js';constdataset=DatasetSchema.parse({name: 'sales',label: 'Sales',object: 'opportunity',dimensions: [{name: 'region',field: 'region',type: 'string'}],measures: [{name: 'revenue',aggregate: 'sum',field: 'amount'}],});/** 两个租户的行在同一张物理表里 */constTABLE=[{id: 1,organization_id: 'org_A',region: 'West',amount: 100},{id: 2,organization_id: 'org_B',region: 'East',amount: 900},];constreadScope=(_o: string,context?: ExecutionContext): FilterCondition|undefined=>context?.tenantId ? {organization_id: context.tenantId} : undefined;/** 诚实的 aggregate 桥:它会真的应用收到的 filter。所以谓词没传到 = 真漏,不是 stub 造假 */functionmakeAggregate(seen: Record<string,unknown>[]){returnasync(_objectName: string,opts: any)=>{seen.push({filter: opts.filter});constrows=TABLE.filter((r)=>Object.entries(opts.filter??{}).every(([k,v])=>(rasany)[k]===v));constbuckets=newMap<string,Record<string,unknown>>();for(constrofrows){constkey=(opts.groupBy??[]).map((g: string)=>String((rasany)[g])).join('|');constb=buckets.get(key)??Object.fromEntries((opts.groupBy??[]).map((g: string)=>[g,(rasany)[g]]));for(constaofopts.aggregations??[]){if(a.method==='sum')b[a.alias]=Number(b[a.alias]??0)+Number((rasany)[a.field]??0);}buckets.set(key,b);}return[...buckets.values()];};}constctxA={tenantId: 'org_A',userId: 'u_a'}asExecutionContext;describe('ObjectQL path — read scope',()=>{it('scopes a plain aggregate query to the caller tenant',async()=>{constcompiled=compileDataset(dataset);constseen: Record<string,unknown>[]=[];constservice=newAnalyticsService({cubes: [compiled.cube],queryCapabilities: ()=>({nativeSql: false,objectqlAggregate: true,inMemory: false}),executeAggregate: makeAggregate(seen),getReadScope: readScope,});constresult=awaitservice.query({cube: 'sales',dimensions: ['region'],measures: ['revenue']},ctxA);expect(result.rows).toEqual([{region: 'West',revenue: 100}]);expect(seen[0].filter).toMatchObject({organization_id: 'org_A'});});it('scopes when NativeSQL declines at runtime (RAW_SQL_UNSUPPORTED fallback)',async()=>{constcompiled=compileDataset(dataset);constseen: Record<string,unknown>[]=[];constservice=newAnalyticsService({cubes: [compiled.cube],// 两个都 advertise —— 正是 plugin auto-bridge 的默认产物queryCapabilities: ()=>({nativeSql: true,objectqlAggregate: true,inMemory: false}),executeRawSql: async()=>{consterr=newError('driver cannot run SQL')asError&{code: string};err.code='RAW_SQL_UNSUPPORTED';throwerr;},executeAggregate: makeAggregate(seen),getReadScope: readScope,});constresult=awaitservice.query({cube: 'sales',dimensions: ['region'],measures: ['revenue']},ctxA);expect(seen[0].filter).toMatchObject({organization_id: 'org_A'});expect(result.rows.map((r)=>r.region)).toEqual(['West']);});it('scopes a date-bucketed query (NativeSQL declines on granularity, even on SQL drivers)',async()=>{constcompiled=compileDataset(DatasetSchema.parse({name: 'sales_t',label: 'Sales',object: 'opportunity',dimensions: [{name: 'created',field: 'created_at',type: 'date'}],measures: [{name: 'revenue',aggregate: 'sum',field: 'amount'}],}));constseen: Record<string,unknown>[]=[];constservice=newAnalyticsService({cubes: [compiled.cube],queryCapabilities: ()=>({nativeSql: true,objectqlAggregate: true,inMemory: false}),executeRawSql: async()=>{thrownewError('should not be reached');},executeAggregate: makeAggregate(seen),getReadScope: readScope,});awaitservice.query({cube: 'sales_t',measures: ['revenue'],timeDimensions: [{dimension: 'created',granularity: 'month'}],},ctxA);expect(seen[0].filter).toMatchObject({organization_id: 'org_A'});});});实际输出 —— org_A 的用户拿到了 org_B 的行:
FAIL ObjectQL path — read scope > scopes a plain aggregate query to the caller tenant
AssertionError: expected [ …(2) ] to deeply equal [ { region: 'West', revenue: 100 } ]
[
{ "region": "West", "revenue": 100 },
+ { "region": "East", "revenue": 900 }, ← 另一个租户的数据
]
Test Files 1 failed | 14 passed (15)
Tests 3 failed | 170 passed (173)
严重度评估:P1
影响边界(诚实说明)
- 仅影响依赖 RLS/多租户的部署。没装 plugin-security 的部署本来就没有 RLS 可绕。
- 需要 analytics 服务确实配了 read-scope provider(即 SecurityPlugin 在场并被 auto-bridge)。
- 我未做的验证:没有在跑起来的完整 stack 上用真实非管理员账号打 HTTP
/analytics/query 端到端复现。上面的证据是 service 层失败测试 + engine middleware fall-open 分支的代码路径确认。端到端复现建议在修复 PR 里补。
次要问题(同源)
ObjectQLStrategy.generateSql(objectql-strategy.ts:117-149)生成的 SQL 完全没有 WHERE 子句,所以 /analytics/sql 的预览会展示未加 scope 的 SQL,与实际应有的执行语义不符。
修复方向(未实现,供讨论)
- 直接补第 1 层:
ObjectQLStrategy.execute 把 ctx.getReadScope(objectName) AND 进 filter。注意 read scope 是 Mongo 形状的 FilterCondition,可直接并入 filter 对象,但同字段冲突时必须走 $and,不能按 key 覆盖(否则用户传的 filter 会顶掉安全谓词)。 - 补第 2 层(纵深防御):让
executeAggregate 契约携带 context,使 engine 侧 RLS 也能生效。这需要在 spec 的 executeAggregate options 里加 context,并把 ExecutionContext 透进 StrategyContext —— 目前 StrategyContext 只有预解析好的 read-scope map,不带 user/session。 - 待定的设计问题:ObjectQL 路径上联表对象的 scope 怎么办。scope map 覆盖了 joins,但
engine.aggregate 的 where 不易表达"对被联对象加谓词"。在补 (1) 之前需要先决定:是拒绝跨对象查询,还是接受 base-only scoping。 - 护栏:给
dataset-rls-integration.test.ts 增加 objectqlAggregate: true 的对照用例矩阵 —— 当前测试钉死 false 正是这个缺口活了这么久的原因。
🤖 Generated with Claude Code
结论
ObjectQLStrategy 完全不消费
getReadScope,导致走该路径的 analytics 聚合查询无任何 RLS/租户谓词。两层防御同时失效,不是纵深防御下的单点缺口。#2852修的是同一类问题(route 层没传 context),但那次修复只让 NativeSQL 路径拿到了 scope;ObjectQL 路径从未被纳入 ADR-0021 D-C。objectql-strategy.ts的 git history 里没有任何一次 RLS 相关改动。两层防御为何同时失效
第 1 层 — analytics 的
getReadScope:未被调用。getReadScope在 strategies 里只出现在native-sql-strategy.ts:200-201:objectql-strategy.ts里grep -n getReadScope零命中。它的filter只由normalizeAnalyticsFilters(query)(即用户自己传的 filter)构成,见objectql-strategy.ts:67-93。而 spec 契约写得很明确 —— 这个谓词是 MUST:
第 2 层 — engine 的 security middleware:被 anonymous fall-open 跳过。
plugin.ts:197-208的 auto-bridge 调用 engine 时不带context:engine 侧其实是支持的(
engine.ts:3339context: mergeReadContext(query?.context, options?.context)),且aggregate确实在 middleware 的读路径覆盖范围内(security-plugin.ts:939)。但 context 缺失时,middleware 在到达 RLS 注入之前就 fall-open 返回了:无 context ⇒
positions=[]、permissions=[]、userId为空 ⇒ 命中 fall-open ⇒ 第 939 行往后的 read-scope/RLS 注入根本执行不到。裸 SQL 桥同样无 context(
plugin.ts:232-242,engine.execute(knexSql, { args: params }))。触发条件比预想的宽 —— 不是只在 in-memory driver 上
NativeSQL 声明失败即回落 ObjectQL。三个触发点,最常见的一个和 driver 无关:
timeDimensions[].granularity一旦有值,NativeSQL 直接return false:RAW_SQL_UNSUPPORTED运行时回落 —— in-memory driver(dev/demo/测试),analytics-service.ts:423-428。native-sql-strategy.ts:40-50。复现(失败测试)
放到
packages/services/service-analytics/src/__tests__/下跑即可。三个用例全红;同 package 其余 170 个测试全绿,所以不是 harness 问题。现有
dataset-rls-integration.test.ts覆盖不到这里的原因:它每个用例都钉死了queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, ... })—— RLS 测试从来没走过 ObjectQL 路径。实际输出 ——
org_A的用户拿到了org_B的行:严重度评估:P1
count配细粒度分组等同于枚举。#2852同类问题被标为security+ p2。本条在 authz(D10): getReadFilter 缺 delegator 交集 — analytics read-scope 对 agent 上下文不与委托人 RLS 求交(原 Gap 2 /analytics/query 无 context 已修复) #2852 修复之后依然成立且触发面更宽,故建议 p1。影响边界(诚实说明)
/analytics/query端到端复现。上面的证据是 service 层失败测试 + engine middleware fall-open 分支的代码路径确认。端到端复现建议在修复 PR 里补。次要问题(同源)
ObjectQLStrategy.generateSql(objectql-strategy.ts:117-149)生成的 SQL 完全没有 WHERE 子句,所以/analytics/sql的预览会展示未加 scope 的 SQL,与实际应有的执行语义不符。修复方向(未实现,供讨论)
ObjectQLStrategy.execute把ctx.getReadScope(objectName)AND 进filter。注意 read scope 是 Mongo 形状的FilterCondition,可直接并入filter对象,但同字段冲突时必须走$and,不能按 key 覆盖(否则用户传的 filter 会顶掉安全谓词)。executeAggregate契约携带context,使 engine 侧 RLS 也能生效。这需要在 spec 的executeAggregateoptions 里加context,并把ExecutionContext透进StrategyContext—— 目前StrategyContext只有预解析好的 read-scope map,不带 user/session。engine.aggregate的 where 不易表达"对被联对象加谓词"。在补 (1) 之前需要先决定:是拒绝跨对象查询,还是接受 base-only scoping。dataset-rls-integration.test.ts增加objectqlAggregate: true的对照用例矩阵 —— 当前测试钉死false正是这个缺口活了这么久的原因。🤖 Generated with Claude Code