结论
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 无关:
- 任何日期分桶查询 ——
timeDimensions[].granularity 一旦有值,NativeSQL 直接 return false:
// native-sql-strategy.ts:30
if (query.timeDimensions?.some((td) => !!td.granularity)) return false;
"按月看营收" 是最典型的 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';
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 层:
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