从 #3878 分出来的第二条分歧。#3878 是"同一个 URL 两种请求体";这一条是同一个 URL 两种作用域语义 —— 降级实现把调用者身份丢在门口,聚合以无主体身份落到引擎,RLS/租户谓词整条不注入。
链路(逐跳,均核对于 origin/main @ 93f267f )
dispatcher 正确地把身份传了下去:
packages/runtime/src/domains/analytics.ts:45 → analyticsService.query(body, context?.executionContext)
该行上方的注释正是 authz(D10): getReadFilter 缺 delegator 交集 — analytics read-scope 对 agent 上下文不与委托人 RLS 求交(原 Gap 2 /analytics/query 无 context 已修复) #2852 修真引擎时留下的:"Without it, getReadScope(object, undefined) returned no filter and the query ran UNSCOPED — an authenticated caller saw rows RLS would otherwise hide."
降级 shim 的 query 是一元函数 ,第二个参数当场丢弃:
packages/metadata-protocol/src/plugin.ts:139 → query: async (body: any) => {
analyticsQuery 调引擎时也不带 context(engine.aggregate 的第三参 EngineReadOptions 没传,query.context 也没设):
packages/metadata-protocol/src/protocol.ts:3675 → await this.engine.aggregate(object, { where, groupBy, aggregations })
引擎侧把两个来源合并,两个都是 undefined:
packages/objectql/src/engine.ts:3696 → context: mergeReadContext(query?.context, options?.context) → undefined
(引擎只有 txStore 一个 AsyncLocalStorage,用于事务;没有任何环境态身份兜底。)
安全中间件的空主体分支放行:
packages/plugins/plugin-security/src/security-plugin.ts:847 → positions.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId → return next()
RLS 过滤与租户谓词都不再注入。
真引擎不走这条路:AnalyticsService.query(query, context?) 收下 context,并有自己的 fail-closed getReadScope 预解析(#3597 那一轮补的)。#2852 / #3597 修的都是真引擎那一半,降级这一半从未跟上。
可达性(写清楚,免得高估也免得低估)
需要同时满足三条:宿主挂了 HTTP dispatcher + 挂了 SecurityPlugin + 没 挂 AnalyticsServicePlugin。
❌ os serve 默认/full preset:serve.ts:202 的 ALWAYS_ON_CAPABILITIES 含 analytics,真引擎装上,不受影响。
❌ os serve --preset minimal:tiers 只剩 ['core'],serve.ts:1401 的 tierEnabled('auth') 为 false,AuthPlugin/SecurityPlugin 都不装 —— 此配置下本来就没有 RLS 可绕,不构成越权。
❌ 托管环境:宿主侧 defaultRequires 强制 analytics,真引擎装上。
✅ 程序化嵌入 :createStandaloneStack() / createObjectQLKernel() + SecurityPlugin + dispatcher,但没有单独挂 AnalyticsServicePlugin —— 这是被文档支持的用法,且 analytics 槽位默认已被 shim 占住 ,作者不会收到任何"你没装分析引擎"的信号。
✅ 任何按 requires 装配、而该 bundle 未声明 analytics 的多环境宿主。
结论:不是发行默认配置里的洞 ,但落在一个合法且无提示的装配组合上,且没有任何一层会告诉部署者作用域已经失效 —— 它返回 200 和一份看起来正常的聚合。
同一处的第二个后果:契约里的过滤条件被静默忽略
AnalyticsQuery 的规范过滤字段是 where(spec/data/analytics.zod.ts:154,"canonical Query DSL FilterCondition")。降级实现只读 query.filters(protocol.ts:3661),而 filters 根本不是 AnalyticsQuery 的字段。
所以一个完全符合契约 的带过滤请求,在降级路径上过滤条件被丢掉,返回全表聚合。objectui 的 dashboard 正是按契约发的(payload.where = params.filter)。两条叠加:既没有 RLS 谓词,也没有调用者自己的过滤 —— 返回的是该对象的全量 聚合。
修法
倾向 A :按 #3878 的结论退役 shim,槽位空着,路由回到 domains/analytics.ts:33 已有的 404。一条路径消失,就不用再给它补第二遍安全门(#3770 vs #3875 已经补过一次两遍了)。
若决定保留 shim,则至少要:query 收下第二参并原样传给 analyticsQuery → analyticsQuery 用 engine.aggregate(object, ast, { context }) 传下去 → 同时认 where。并补一条门:降级实现与真实现在同一份 RLS 断言下都要绿 ,否则下次还是只修一半。
关联:#3878 (同一 URL 两种请求体)、#2852 、#3597 、#3770 /#3866 、#3867 /#3875 、ADR-0076 D10/D12。
从 #3878 分出来的第二条分歧。#3878 是"同一个 URL 两种请求体";这一条是同一个 URL 两种作用域语义 —— 降级实现把调用者身份丢在门口,聚合以无主体身份落到引擎,RLS/租户谓词整条不注入。
链路(逐跳,均核对于
origin/main@ 93f267f)dispatcher 正确地把身份传了下去:
packages/runtime/src/domains/analytics.ts:45→analyticsService.query(body, context?.executionContext)该行上方的注释正是 authz(D10): getReadFilter 缺 delegator 交集 — analytics read-scope 对 agent 上下文不与委托人 RLS 求交(原 Gap 2 /analytics/query 无 context 已修复) #2852 修真引擎时留下的:"Without it,
getReadScope(object, undefined)returned no filter and the query ran UNSCOPED — an authenticated caller saw rows RLS would otherwise hide."降级 shim 的
query是一元函数,第二个参数当场丢弃:packages/metadata-protocol/src/plugin.ts:139→query: async (body: any) => {analyticsQuery调引擎时也不带 context(engine.aggregate的第三参EngineReadOptions没传,query.context也没设):packages/metadata-protocol/src/protocol.ts:3675→await this.engine.aggregate(object, { where, groupBy, aggregations })引擎侧把两个来源合并,两个都是 undefined:
packages/objectql/src/engine.ts:3696→context: mergeReadContext(query?.context, options?.context)→undefined(引擎只有
txStore一个 AsyncLocalStorage,用于事务;没有任何环境态身份兜底。)安全中间件的空主体分支放行:
packages/plugins/plugin-security/src/security-plugin.ts:847→positions.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId→return next()RLS 过滤与租户谓词都不再注入。
真引擎不走这条路:
AnalyticsService.query(query, context?)收下 context,并有自己的 fail-closedgetReadScope预解析(#3597 那一轮补的)。#2852 / #3597 修的都是真引擎那一半,降级这一半从未跟上。可达性(写清楚,免得高估也免得低估)
需要同时满足三条:宿主挂了 HTTP dispatcher + 挂了
SecurityPlugin+ 没挂AnalyticsServicePlugin。os serve默认/full preset:serve.ts:202的ALWAYS_ON_CAPABILITIES含analytics,真引擎装上,不受影响。os serve --preset minimal:tiers 只剩['core'],serve.ts:1401的tierEnabled('auth')为 false,AuthPlugin/SecurityPlugin 都不装 —— 此配置下本来就没有 RLS 可绕,不构成越权。defaultRequires强制analytics,真引擎装上。createStandaloneStack()/createObjectQLKernel()+SecurityPlugin+ dispatcher,但没有单独挂AnalyticsServicePlugin—— 这是被文档支持的用法,且 analytics 槽位默认已被 shim 占住,作者不会收到任何"你没装分析引擎"的信号。requires装配、而该 bundle 未声明analytics的多环境宿主。结论:不是发行默认配置里的洞,但落在一个合法且无提示的装配组合上,且没有任何一层会告诉部署者作用域已经失效 —— 它返回 200 和一份看起来正常的聚合。
同一处的第二个后果:契约里的过滤条件被静默忽略
AnalyticsQuery的规范过滤字段是where(spec/data/analytics.zod.ts:154,"canonical Query DSL FilterCondition")。降级实现只读query.filters(protocol.ts:3661),而filters根本不是AnalyticsQuery的字段。所以一个完全符合契约的带过滤请求,在降级路径上过滤条件被丢掉,返回全表聚合。objectui 的 dashboard 正是按契约发的(
payload.where = params.filter)。两条叠加:既没有 RLS 谓词,也没有调用者自己的过滤 —— 返回的是该对象的全量聚合。修法
倾向 A:按 #3878 的结论退役 shim,槽位空着,路由回到
domains/analytics.ts:33已有的 404。一条路径消失,就不用再给它补第二遍安全门(#3770 vs #3875 已经补过一次两遍了)。若决定保留 shim,则至少要:
query收下第二参并原样传给analyticsQuery→analyticsQuery用engine.aggregate(object, ast, { context })传下去 → 同时认where。并补一条门:降级实现与真实现在同一份 RLS 断言下都要绿,否则下次还是只修一半。关联:#3878(同一 URL 两种请求体)、#2852、#3597、#3770/#3866、#3867/#3875、ADR-0076 D10/D12。