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.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-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.execute 把 ctx.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

Activity

  1. os-zhuang commented on Jul 27, 2026

    @os-zhuang
    ContributorAuthor

    补充:engine 侧独立核查结果(佐证 + 三个新事实)

    对 engine/driver/ambient-context 做了一轮独立追查,结论与正文一致,并补出三点。

    1. engine 的 fall-open 是"全开",且下游没有任何一层兜底

    aggregate 的 options 本来就支持 context —— BaseEngineOptionsSchema(spec/src/data/data-engine.zod.ts:56-59)带 context: ExecutionContextSchema.optional()。是 .optional(),所以类型系统从不强迫调用方传;analytics 桥就是没传的那个。

    无 context 命中 security-plugin.ts:769-781 的 fall-open 后,被跳过的不止 RLS 注入:CRUD grant 检查(:899)、aggregate 的 FLS 输入门(:1139)、field-predicate oracle 守卫(:1500)、以及唯一会把租户/归属谓词 AND 进 opCtx.ast.where 的 :1534-1567 全部跳过。

    Layer 0 租户墙本来是 fail-closed 的,但根本够不着:tenant-layer.ts:146 有
    if (!input.organizationId) return { ...RLS_DENY_FILTER };,可它只被 computeLayeredRlsFilter 调用,而后者在 return next() 的下游。

    driver 层同样不兜底 —— driver-sql/src/sql-driver.ts:2829-2830:

    const tenantId = (options as any)?.tenantId;
    if (tenantId === undefined || tenantId === null || tenantId === '') return builder;

    其 JSDoc(:2819-2820)直说:"Without a tenantId the call is treated as an unscoped/admin path"。

    不存在 ambient context 可以救:全仓 AsyncLocalStorage 只有 3 处(engine 的事务句柄 engine.ts:317、observability perf、better-auth polyfill),没有一处携带身份;getCurrentContext/requestContext/getExecutionContext 全仓零命中。plugin-auth 的中间件(auth-plugin.ts:744-751)自己写着 // Future: resolve session from AsyncLocalStorage 然后 await next() —— 是个 no-op。context 必须显式传,没有恢复路径。

    2. 新事实:同一条无 scope 的桥还被 label 解析复用,泄的是记录名不是聚合值

    plugin.ts:348-364 的 fetchRecordLabels 复用同一个 executeAggregate:

    const rows = await executeAggregate(targetObject, {
      groupBy: ['id', displayField],
      aggregations: [{ field: 'id', method: 'count', alias: '_c' }],
      filter: { id: { $in: ids } },        // ← 无 read scope、无 context
    });

    groupBy: ['id', displayField] 等于"每条记录一行",返回的是真实记录的显示名。需要精确区分两条路径:

    • ObjectQL 路径:聚合本身就没 scope ⇒ 喂给它的 ids 已经含别的租户的记录 ⇒ 再把这些 id 解析成名字。这是泄露放大器:把"泄露的聚合值"升级成"泄露的记录名",逼近逐行披露。
    • NativeSQL 路径:聚合是有 scope 的,ids 已被限制,所以这里目前不越界。但这条 lookup 自身无 scope 属于潜在债 —— 一旦将来 ids 来源变宽就会直接变成漏洞。

    这一点把正文"泄露的是聚合值而非原始行"的说法往严重方向修正了。

    3. plugin-sharing 是唯一 fail-closed 的一层,但覆盖面很窄(算不上缓解)

    plugin-sharing/src/sharing-service.ts:138-143 确实对 !userId fail-closed(返回 { id: '__deny_all__' }),且对 aggregate 已接线(sharing-plugin.ts:427)。但它要先过三道前置(:132-137):不在 bypass 列表、effectiveSharingModel(schema) === 'private'、且 hasOwnerField(schema)。

    所以 public/read 模型的对象、或没有 owner_id 的对象,它返回 null,不设防。而且 @objectstack/plugin-sharing 不是 packages/runtime 的依赖(runtime 只依赖 plugin-security,packages/runtime/package.json:31),很多部署根本没装。

    净效果:对一个 public / 无 owner 字段的租户对象,无 context 的 aggregate 读的是所有租户的全部行。

    4. 顺带澄清一个"是否可达"的疑问

    追查过程中曾存疑"这条 fall-open 能否从已认证的 HTTP 请求打到"。答案是能,而且已认证请求正是受影响的那条路径:runtime/src/domains/analytics.ts:44 确实把 context?.executionContext 传进了 analyticsService.query(...)(这正是 #2852 的修复),context 顺利到达 AnalyticsService,然后死在 strategy 边界上 —— StrategyContext 不携带 user/session,桥也不转发。所以不是"没认证所以没 context",而是"认证了、context 也到了,但在最后一跳被丢掉"。

    5. 另外两处 aggregate 与 find 的差异(与本 issue 相邻,不必在本 issue 内解决)

    • aggregate 无结果侧 FLS 掩码:security-plugin.ts:1578 的 ['find','findOne','insert','update'] 不含 aggregate;补偿控制是输入侧的 :1139-1177,而它也在 fall-open 内。
    • aggregate 不触发 beforeFind/afterFind:find 在 engine.ts:2350/2373 触发,aggregate 的执行体(engine.ts:3342-3385)一个都不触发 —— 任何靠读钩子注入过滤的逻辑对 aggregate 无效。

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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