Skip to content

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

Description

@os-zhuang

结论

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.tsgrep -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 无关:

  1. 任何日期分桶查询 —— timeDimensions[].granularity 一旦有值,NativeSQL 直接 return false:
    // native-sql-strategy.ts:30if(query.timeDimensions?.some((td)=>!!td.granularity))returnfalse;
    "按月看营收" 是最典型的 dashboard 形状。Postgres/SQLite 上照样漏 —— 这不是 exotic driver 的边角问题。
  2. RAW_SQL_UNSUPPORTED 运行时回落 —— in-memory driver(dev/demo/测试),analytics-service.ts:423-428
  3. 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. 直接补第 1 层:ObjectQLStrategy.executectx.getReadScope(objectName) AND 进 filter。注意 read scope 是 Mongo 形状的 FilterCondition,可直接并入 filter 对象,但同字段冲突时必须走 $and,不能按 key 覆盖(否则用户传的 filter 会顶掉安全谓词)。
  2. 补第 2 层(纵深防御):让 executeAggregate 契约携带 context,使 engine 侧 RLS 也能生效。这需要在 spec 的 executeAggregate options 里加 context,并把 ExecutionContext 透进 StrategyContext —— 目前 StrategyContext 只有预解析好的 read-scope map,不带 user/session。
  3. 待定的设计问题:ObjectQL 路径上联表对象的 scope 怎么办。scope map 覆盖了 joins,但 engine.aggregate 的 where 不易表达"对被联对象加谓词"。在补 (1) 之前需要先决定:是拒绝跨对象查询,还是接受 base-only scoping。
  4. 护栏:给 dataset-rls-integration.test.ts 增加 objectqlAggregate: true 的对照用例矩阵 —— 当前测试钉死 false 正是这个缺口活了这么久的原因。

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions