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 (typeof ctx.getReadScope !== 'function') return;
const filter = 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-208
const rows = await engine.aggregate(objectName, {
  where: filter, groupBy, aggregations, timezone,   // ← 没有 context
});

engine 侧其实是支持的(engine.ts:3339 context: 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
) {
  return next();          // ← 整个安全中间件被跳过,包括 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:30
    if (query.timeDimensions?.some((td) => !!td.granularity)) return false;
    "按月看营收" 是最典型的 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';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { compileDataset } from '../dataset-compiler.js';

const dataset = DatasetSchema.parse({
  name: 'sales', label: 'Sales', object: 'opportunity',
  dimensions: [{ name: 'region', field: 'region', type: 'string' }],
  measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
});

/** 两个租户的行在同一张物理表里 */
const TABLE = [
  { id: 1, organization_id: 'org_A', region: 'West', amount: 100 },
  { id: 2, organization_id: 'org_B', region: 'East', amount: 900 },
];

const readScope = (_o: string, context?: ExecutionContext): FilterCondition | undefined =>
  context?.tenantId ? { organization_id: context.tenantId } : undefined;

/** 诚实的 aggregate 桥:它会真的应用收到的 filter。所以谓词没传到 = 真漏,不是 stub 造假 */
function makeAggregate(seen: Record<string, unknown>[]) {
  return async (_objectName: string, opts: any) => {
    seen.push({ filter: opts.filter });
    const rows = TABLE.filter((r) =>
      Object.entries(opts.filter ?? {}).every(([k, v]) => (r as any)[k] === v));
    const buckets = new Map<string, Record<string, unknown>>();
    for (const r of rows) {
      const key = (opts.groupBy ?? []).map((g: string) => String((r as any)[g])).join('|');
      const b = buckets.get(key) ?? Object.fromEntries((opts.groupBy ?? []).map((g: string) => [g, (r as any)[g]]));
      for (const a of opts.aggregations ?? []) {
        if (a.method === 'sum') b[a.alias] = Number(b[a.alias] ?? 0) + Number((r as any)[a.field] ?? 0);
      }
      buckets.set(key, b);
    }
    return [...buckets.values()];
  };
}

const ctxA = { tenantId: 'org_A', userId: 'u_a' } as ExecutionContext;

describe('ObjectQL path — read scope', () => {
  it('scopes a plain aggregate query to the caller tenant', async () => {
    const compiled = compileDataset(dataset);
    const seen: Record<string, unknown>[] = [];
    const service = new AnalyticsService({
      cubes: [compiled.cube],
      queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
      executeAggregate: makeAggregate(seen),
      getReadScope: readScope,
    });
    const result = await service.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 () => {
    const compiled = compileDataset(dataset);
    const seen: Record<string, unknown>[] = [];
    const service = new AnalyticsService({
      cubes: [compiled.cube],
      // 两个都 advertise —— 正是 plugin auto-bridge 的默认产物
      queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
      executeRawSql: async () => {
        const err = new Error('driver cannot run SQL') as Error & { code: string };
        err.code = 'RAW_SQL_UNSUPPORTED';
        throw err;
      },
      executeAggregate: makeAggregate(seen),
      getReadScope: readScope,
    });
    const result = await service.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 () => {
    const compiled = 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' }],
    }));
    const seen: Record<string, unknown>[] = [];
    const service = new AnalyticsService({
      cubes: [compiled.cube],
      queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
      executeRawSql: async () => { throw new Error('should not be reached'); },
      executeAggregate: makeAggregate(seen),
      getReadScope: readScope,
    });
    await service.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

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions